duck_record 0.0.3 → 0.0.5

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,262 @@
1
+ module DuckRecord
2
+ # = Active Record Autosave Association
3
+ #
4
+ # AutosaveAssociation is a module that takes care of automatically saving
5
+ # associated records when their parent is saved. In addition to saving, it
6
+ # also destroys any associated records that were marked for destruction.
7
+ # (See #mark_for_destruction and #marked_for_destruction?).
8
+ #
9
+ # Saving of the parent, its associations, and the destruction of marked
10
+ # associations, all happen inside a transaction. This should never leave the
11
+ # database in an inconsistent state.
12
+ #
13
+ # If validations for any of the associations fail, their error messages will
14
+ # be applied to the parent.
15
+ #
16
+ # Note that it also means that associations marked for destruction won't
17
+ # be destroyed directly. They will however still be marked for destruction.
18
+ #
19
+ # Note that <tt>autosave: false</tt> is not same as not declaring <tt>:autosave</tt>.
20
+ # When the <tt>:autosave</tt> option is not present then new association records are
21
+ # saved but the updated association records are not saved.
22
+ #
23
+ # == Validation
24
+ #
25
+ # Child records are validated unless <tt>:validate</tt> is +false+.
26
+ #
27
+ # == Callbacks
28
+ #
29
+ # Association with autosave option defines several callbacks on your
30
+ # model (before_save, after_create, after_update). Please note that
31
+ # callbacks are executed in the order they were defined in
32
+ # model. You should avoid modifying the association content, before
33
+ # autosave callbacks are executed. Placing your callbacks after
34
+ # associations is usually a good practice.
35
+ #
36
+ # === One-to-one Example
37
+ #
38
+ # class Post < ActiveRecord::Base
39
+ # has_one :author, autosave: true
40
+ # end
41
+ #
42
+ # Saving changes to the parent and its associated model can now be performed
43
+ # automatically _and_ atomically:
44
+ #
45
+ # post = Post.find(1)
46
+ # post.title # => "The current global position of migrating ducks"
47
+ # post.author.name # => "alloy"
48
+ #
49
+ # post.title = "On the migration of ducks"
50
+ # post.author.name = "Eloy Duran"
51
+ #
52
+ # post.save
53
+ # post.reload
54
+ # post.title # => "On the migration of ducks"
55
+ # post.author.name # => "Eloy Duran"
56
+ #
57
+ # Destroying an associated model, as part of the parent's save action, is as
58
+ # simple as marking it for destruction:
59
+ #
60
+ # post.author.mark_for_destruction
61
+ # post.author.marked_for_destruction? # => true
62
+ #
63
+ # Note that the model is _not_ yet removed from the database:
64
+ #
65
+ # id = post.author.id
66
+ # Author.find_by(id: id).nil? # => false
67
+ #
68
+ # post.save
69
+ # post.reload.author # => nil
70
+ #
71
+ # Now it _is_ removed from the database:
72
+ #
73
+ # Author.find_by(id: id).nil? # => true
74
+ #
75
+ # === One-to-many Example
76
+ #
77
+ # When <tt>:autosave</tt> is not declared new children are saved when their parent is saved:
78
+ #
79
+ # class Post < ActiveRecord::Base
80
+ # has_many :comments # :autosave option is not declared
81
+ # end
82
+ #
83
+ # post = Post.new(title: 'ruby rocks')
84
+ # post.comments.build(body: 'hello world')
85
+ # post.save # => saves both post and comment
86
+ #
87
+ # post = Post.create(title: 'ruby rocks')
88
+ # post.comments.build(body: 'hello world')
89
+ # post.save # => saves both post and comment
90
+ #
91
+ # post = Post.create(title: 'ruby rocks')
92
+ # post.comments.create(body: 'hello world')
93
+ # post.save # => saves both post and comment
94
+ #
95
+ # When <tt>:autosave</tt> is true all children are saved, no matter whether they
96
+ # are new records or not:
97
+ #
98
+ # class Post < ActiveRecord::Base
99
+ # has_many :comments, autosave: true
100
+ # end
101
+ #
102
+ # post = Post.create(title: 'ruby rocks')
103
+ # post.comments.create(body: 'hello world')
104
+ # post.comments[0].body = 'hi everyone'
105
+ # post.comments.build(body: "good morning.")
106
+ # post.title += "!"
107
+ # post.save # => saves both post and comments.
108
+ #
109
+ # Destroying one of the associated models as part of the parent's save action
110
+ # is as simple as marking it for destruction:
111
+ #
112
+ # post.comments # => [#<Comment id: 1, ...>, #<Comment id: 2, ...]>
113
+ # post.comments[1].mark_for_destruction
114
+ # post.comments[1].marked_for_destruction? # => true
115
+ # post.comments.length # => 2
116
+ #
117
+ # Note that the model is _not_ yet removed from the database:
118
+ #
119
+ # id = post.comments.last.id
120
+ # Comment.find_by(id: id).nil? # => false
121
+ #
122
+ # post.save
123
+ # post.reload.comments.length # => 1
124
+ #
125
+ # Now it _is_ removed from the database:
126
+ #
127
+ # Comment.find_by(id: id).nil? # => true
128
+ module NestedValidateAssociation
129
+ extend ActiveSupport::Concern
130
+
131
+ module AssociationBuilderExtension #:nodoc:
132
+ def self.build(model, reflection)
133
+ model.send(:add_nested_validate_association_callbacks, reflection)
134
+ end
135
+
136
+ def self.valid_options
137
+ []
138
+ end
139
+ end
140
+
141
+ included do
142
+ Associations::Builder::Association.extensions << AssociationBuilderExtension
143
+ mattr_accessor :index_nested_attribute_errors, instance_writer: false
144
+ self.index_nested_attribute_errors = false
145
+ end
146
+
147
+ module ClassMethods # :nodoc:
148
+ private
149
+
150
+ def define_non_cyclic_method(name, &block)
151
+ return if method_defined?(name)
152
+ define_method(name) do |*args|
153
+ result = true; @_already_called ||= {}
154
+ # Loop prevention for validation of associations
155
+ unless @_already_called[name]
156
+ begin
157
+ @_already_called[name] = true
158
+ result = instance_eval(&block)
159
+ ensure
160
+ @_already_called[name] = false
161
+ end
162
+ end
163
+
164
+ result
165
+ end
166
+ end
167
+
168
+ # Adds validation and save callbacks for the association as specified by
169
+ # the +reflection+.
170
+ #
171
+ # For performance reasons, we don't check whether to validate at runtime.
172
+ # However the validation and callback methods are lazy and those methods
173
+ # get created when they are invoked for the very first time. However,
174
+ # this can change, for instance, when using nested attributes, which is
175
+ # called _after_ the association has been defined. Since we don't want
176
+ # the callbacks to get defined multiple times, there are guards that
177
+ # check if the save or validation methods have already been defined
178
+ # before actually defining them.
179
+ def add_nested_validate_association_callbacks(reflection)
180
+ validation_method = :"validate_associated_records_for_#{reflection.name}"
181
+ if reflection.validate? && !method_defined?(validation_method)
182
+ if reflection.collection?
183
+ method = :validate_collection_association
184
+ else
185
+ method = :validate_single_association
186
+ end
187
+
188
+ define_non_cyclic_method(validation_method) do
189
+ send(method, reflection)
190
+ # TODO: remove the following line as soon as the return value of
191
+ # callbacks is ignored, that is, returning `false` does not
192
+ # display a deprecation warning or halts the callback chain.
193
+ true
194
+ end
195
+ validate validation_method
196
+ after_validation :_ensure_no_duplicate_errors
197
+ end
198
+ end
199
+ end
200
+
201
+ private
202
+
203
+ # Validate the association if <tt>:validate</tt> or <tt>:autosave</tt> is
204
+ # turned on for the association.
205
+ def validate_single_association(reflection)
206
+ association = association_instance_get(reflection.name)
207
+ record = association&.reader
208
+ association_valid?(reflection, record) if record
209
+ end
210
+
211
+ # Validate the associated records if <tt>:validate</tt> or
212
+ # <tt>:autosave</tt> is turned on for the association specified by
213
+ # +reflection+.
214
+ def validate_collection_association(reflection)
215
+ if association = association_instance_get(reflection.name)
216
+ if records = association.target
217
+ records.each_with_index { |record, index| association_valid?(reflection, record, index) }
218
+ end
219
+ end
220
+ end
221
+
222
+ # Returns whether or not the association is valid and applies any errors to
223
+ # the parent, <tt>self</tt>, if it wasn't. Skips any <tt>:autosave</tt>
224
+ # enabled records if they're marked_for_destruction? or destroyed.
225
+ def association_valid?(reflection, record, index = nil)
226
+ unless valid = record.valid?
227
+ indexed_attribute = !index.nil? && (reflection.options[:index_errors] || DuckRecord::Base.index_nested_attribute_errors)
228
+
229
+ record.errors.each do |attribute, message|
230
+ attribute = normalize_reflection_attribute(indexed_attribute, reflection, index, attribute)
231
+ errors[attribute] << message
232
+ errors[attribute].uniq!
233
+ end
234
+
235
+ record.errors.details.each_key do |attribute|
236
+ reflection_attribute =
237
+ normalize_reflection_attribute(indexed_attribute, reflection, index, attribute).to_sym
238
+
239
+ record.errors.details[attribute].each do |error|
240
+ errors.details[reflection_attribute] << error
241
+ errors.details[reflection_attribute].uniq!
242
+ end
243
+ end
244
+ end
245
+ valid
246
+ end
247
+
248
+ def normalize_reflection_attribute(indexed_attribute, reflection, index, attribute)
249
+ if indexed_attribute
250
+ "#{reflection.name}[#{index}].#{attribute}"
251
+ else
252
+ "#{reflection.name}.#{attribute}"
253
+ end
254
+ end
255
+
256
+ def _ensure_no_duplicate_errors
257
+ errors.messages.each_key do |attribute|
258
+ errors[attribute].uniq!
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,309 @@
1
+ require "thread"
2
+ require "active_support/core_ext/string/filters"
3
+ require "active_support/deprecation"
4
+
5
+ module DuckRecord
6
+ # = Active Record Reflection
7
+ module Reflection # :nodoc:
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ class_attribute :_reflections, instance_writer: false
12
+ self._reflections = {}
13
+ end
14
+
15
+ def self.create(macro, name, options, ar)
16
+ klass = \
17
+ case macro
18
+ when :has_many
19
+ HasManyReflection
20
+ when :has_one
21
+ HasOneReflection
22
+ else
23
+ raise "Unsupported Macro: #{macro}"
24
+ end
25
+
26
+ klass.new(name, options, ar)
27
+ end
28
+
29
+ def self.add_reflection(ar, name, reflection)
30
+ ar.clear_reflections_cache
31
+ ar._reflections = ar._reflections.merge(name.to_s => reflection)
32
+ end
33
+
34
+ # \Reflection enables the ability to examine the associations and aggregations of
35
+ # Active Record classes and objects. This information, for example,
36
+ # can be used in a form builder that takes an Active Record object
37
+ # and creates input fields for all of the attributes depending on their type
38
+ # and displays the associations to other objects.
39
+ #
40
+ # MacroReflection class has info for AggregateReflection and AssociationReflection
41
+ # classes.
42
+ module ClassMethods
43
+ # Returns a Hash of name of the reflection as the key and an AssociationReflection as the value.
44
+ #
45
+ # Account.reflections # => {"balance" => AggregateReflection}
46
+ #
47
+ def reflections
48
+ @__reflections ||= begin
49
+ ref = {}
50
+
51
+ _reflections.each do |name, reflection|
52
+ parent_reflection = reflection.parent_reflection
53
+
54
+ if parent_reflection
55
+ parent_name = parent_reflection.name
56
+ ref[parent_name.to_s] = parent_reflection
57
+ else
58
+ ref[name] = reflection
59
+ end
60
+ end
61
+
62
+ ref
63
+ end
64
+ end
65
+
66
+ # Returns an array of AssociationReflection objects for all the
67
+ # associations in the class. If you only want to reflect on a certain
68
+ # association type, pass in the symbol (<tt>:has_many</tt>, <tt>:has_one</tt>,
69
+ # <tt>:belongs_to</tt>) as the first parameter.
70
+ #
71
+ # Example:
72
+ #
73
+ # Account.reflect_on_all_associations # returns an array of all associations
74
+ # Account.reflect_on_all_associations(:has_many) # returns an array of all has_many associations
75
+ #
76
+ def reflect_on_all_associations(macro = nil)
77
+ association_reflections = reflections.values
78
+ association_reflections.select! { |reflection| reflection.macro == macro } if macro
79
+ association_reflections
80
+ end
81
+
82
+ # Returns the AssociationReflection object for the +association+ (use the symbol).
83
+ #
84
+ # Account.reflect_on_association(:owner) # returns the owner AssociationReflection
85
+ # Invoice.reflect_on_association(:line_items).macro # returns :has_many
86
+ #
87
+ def reflect_on_association(association)
88
+ reflections[association.to_s]
89
+ end
90
+
91
+ def _reflect_on_association(association) #:nodoc:
92
+ _reflections[association.to_s]
93
+ end
94
+
95
+ def clear_reflections_cache # :nodoc:
96
+ @__reflections = nil
97
+ end
98
+ end
99
+
100
+ # Holds all the methods that are shared between MacroReflection and ThroughReflection.
101
+ #
102
+ # AbstractReflection
103
+ # MacroReflection
104
+ # AggregateReflection
105
+ # AssociationReflection
106
+ # HasManyReflection
107
+ # HasOneReflection
108
+ # BelongsToReflection
109
+ # HasAndBelongsToManyReflection
110
+ # ThroughReflection
111
+ # PolymorphicReflection
112
+ # RuntimeReflection
113
+ class AbstractReflection # :nodoc:
114
+ # Returns a new, unsaved instance of the associated class. +attributes+ will
115
+ # be passed to the class's constructor.
116
+ def build_association(attributes, &block)
117
+ klass.new(attributes, &block)
118
+ end
119
+
120
+ # Returns the class name for the macro.
121
+ #
122
+ # <tt>composed_of :balance, class_name: 'Money'</tt> returns <tt>'Money'</tt>
123
+ # <tt>has_many :clients</tt> returns <tt>'Client'</tt>
124
+ def class_name
125
+ @class_name ||= (options[:class_name] || derive_class_name).to_s
126
+ end
127
+
128
+ def check_validity!
129
+ true
130
+ end
131
+
132
+ def alias_candidate(name)
133
+ "#{plural_name}_#{name}"
134
+ end
135
+ end
136
+
137
+ # Base class for AggregateReflection and AssociationReflection. Objects of
138
+ # AggregateReflection and AssociationReflection are returned by the Reflection::ClassMethods.
139
+ class MacroReflection < AbstractReflection
140
+ # Returns the name of the macro.
141
+ #
142
+ # <tt>composed_of :balance, class_name: 'Money'</tt> returns <tt>:balance</tt>
143
+ # <tt>has_many :clients</tt> returns <tt>:clients</tt>
144
+ attr_reader :name
145
+
146
+ # Returns the hash of options used for the macro.
147
+ #
148
+ # <tt>composed_of :balance, class_name: 'Money'</tt> returns <tt>{ class_name: "Money" }</tt>
149
+ # <tt>has_many :clients</tt> returns <tt>{}</tt>
150
+ attr_reader :options
151
+
152
+ attr_reader :active_record
153
+
154
+ attr_reader :plural_name # :nodoc:
155
+
156
+ def initialize(name, options, active_record)
157
+ @name = name
158
+ @options = options
159
+ @active_record = active_record
160
+ @klass = options[:anonymous_class]
161
+ @plural_name = name.to_s.pluralize
162
+ end
163
+
164
+ # Returns the class for the macro.
165
+ #
166
+ # <tt>composed_of :balance, class_name: 'Money'</tt> returns the Money class
167
+ # <tt>has_many :clients</tt> returns the Client class
168
+ def klass
169
+ @klass ||= compute_class(class_name)
170
+ end
171
+
172
+ def compute_class(name)
173
+ name.constantize
174
+ end
175
+
176
+ private
177
+
178
+ def derive_class_name
179
+ name.to_s.camelize
180
+ end
181
+ end
182
+
183
+ # Holds all the metadata about an association as it was specified in the
184
+ # Active Record class.
185
+ class AssociationReflection < MacroReflection #:nodoc:
186
+ # Returns the target association's class.
187
+ #
188
+ # class Author < ActiveRecord::Base
189
+ # has_many :books
190
+ # end
191
+ #
192
+ # Author.reflect_on_association(:books).klass
193
+ # # => Book
194
+ #
195
+ # <b>Note:</b> Do not call +klass.new+ or +klass.create+ to instantiate
196
+ # a new association object. Use +build_association+ or +create_association+
197
+ # instead. This allows plugins to hook into association object creation.
198
+ def klass
199
+ @klass ||= compute_class(class_name)
200
+ end
201
+
202
+ def compute_class(name)
203
+ active_record.send(:compute_type, name)
204
+ end
205
+
206
+ attr_accessor :parent_reflection # Reflection
207
+
208
+ def initialize(name, options, active_record)
209
+ super
210
+ @constructable = calculate_constructable(macro, options)
211
+
212
+ if options[:class_name] && options[:class_name].class == Class
213
+ ActiveSupport::Deprecation.warn(<<-MSG.squish)
214
+ Passing a class to the `class_name` is deprecated and will raise
215
+ an ArgumentError in Rails 5.2. It eagerloads more classes than
216
+ necessary and potentially creates circular dependencies.
217
+
218
+ Please pass the class name as a string:
219
+ `#{macro} :#{name}, class_name: '#{options[:class_name]}'`
220
+ MSG
221
+ end
222
+ end
223
+
224
+ def constructable? # :nodoc:
225
+ @constructable
226
+ end
227
+
228
+ def source_reflection
229
+ self
230
+ end
231
+
232
+ def nested?
233
+ false
234
+ end
235
+
236
+ # Returns the macro type.
237
+ #
238
+ # <tt>has_many :clients</tt> returns <tt>:has_many</tt>
239
+ def macro; raise NotImplementedError; end
240
+
241
+ # Returns whether or not this association reflection is for a collection
242
+ # association. Returns +true+ if the +macro+ is either +has_many+ or
243
+ # +has_and_belongs_to_many+, +false+ otherwise.
244
+ def collection?
245
+ false
246
+ end
247
+
248
+ # Returns whether or not the association should be validated as part of
249
+ # the parent's validation.
250
+ #
251
+ # Unless you explicitly disable validation with
252
+ # <tt>validate: false</tt>, validation will take place when:
253
+ #
254
+ # * you explicitly enable validation; <tt>validate: true</tt>
255
+ # * you use autosave; <tt>autosave: true</tt>
256
+ # * the association is a +has_many+ association
257
+ def validate?
258
+ !options[:validate].nil? ? options[:validate] : collection?
259
+ end
260
+
261
+ # Returns +true+ if +self+ is a +has_one+ reflection.
262
+ def has_one?; false; end
263
+
264
+ def association_class; raise NotImplementedError; end
265
+
266
+ def add_as_source(seed)
267
+ seed
268
+ end
269
+
270
+ protected
271
+
272
+ def actual_source_reflection # FIXME: this is a horrible name
273
+ self
274
+ end
275
+
276
+ private
277
+
278
+ def calculate_constructable(macro, options)
279
+ true
280
+ end
281
+
282
+ def derive_class_name
283
+ class_name = name.to_s
284
+ class_name = class_name.singularize if collection?
285
+ class_name.camelize
286
+ end
287
+ end
288
+
289
+ class HasManyReflection < AssociationReflection # :nodoc:
290
+ def macro; :has_many; end
291
+
292
+ def collection?; true; end
293
+
294
+ def association_class
295
+ Associations::HasManyAssociation
296
+ end
297
+ end
298
+
299
+ class HasOneReflection < AssociationReflection # :nodoc:
300
+ def macro; :has_one; end
301
+
302
+ def has_one?; true; end
303
+
304
+ def association_class
305
+ Associations::HasOneAssociation
306
+ end
307
+ end
308
+ end
309
+ end
@@ -1,3 +1,3 @@
1
1
  module DuckRecord
2
- VERSION = '0.0.3'
2
+ VERSION = '0.0.5'
3
3
  end
data/lib/duck_record.rb CHANGED
@@ -14,6 +14,8 @@ module DuckRecord
14
14
  autoload :Core
15
15
  autoload :Inheritance
16
16
  autoload :ModelSchema
17
+ autoload :NestedAttributes
18
+ autoload :Reflection
17
19
  autoload :Serialization
18
20
  autoload :Translation
19
21
  autoload :Validations
@@ -21,8 +23,10 @@ module DuckRecord
21
23
  eager_autoload do
22
24
  autoload :DuckRecordError, 'duck_record/errors'
23
25
 
26
+ autoload :Associations
24
27
  autoload :AttributeAssignment
25
28
  autoload :AttributeMethods
29
+ autoload :NestedValidateAssociation
26
30
  end
27
31
 
28
32
  module AttributeMethods
@@ -38,10 +42,12 @@ module DuckRecord
38
42
 
39
43
  def self.eager_load!
40
44
  super
41
- ActiveRecord::AttributeMethods.eager_load!
45
+
46
+ DuckRecord::Associations.eager_load!
47
+ DuckRecord::AttributeMethods.eager_load!
42
48
  end
43
49
  end
44
50
 
45
- # ActiveSupport.on_load(:i18n) do
46
- # I18n.load_path << File.dirname(__FILE__) + '/duck_record/locale/en.yml'
47
- # end
51
+ ActiveSupport.on_load(:i18n) do
52
+ I18n.load_path << File.dirname(__FILE__) + '/duck_record/locale/en.yml'
53
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: duck_record
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - jasl
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-03-11 00:00:00.000000000 Z
11
+ date: 2017-03-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -66,6 +66,18 @@ files:
66
66
  - README.md
67
67
  - Rakefile
68
68
  - lib/duck_record.rb
69
+ - lib/duck_record/associations.rb
70
+ - lib/duck_record/associations/association.rb
71
+ - lib/duck_record/associations/builder/association.rb
72
+ - lib/duck_record/associations/builder/collection_association.rb
73
+ - lib/duck_record/associations/builder/has_many.rb
74
+ - lib/duck_record/associations/builder/has_one.rb
75
+ - lib/duck_record/associations/builder/singular_association.rb
76
+ - lib/duck_record/associations/collection_association.rb
77
+ - lib/duck_record/associations/collection_proxy.rb
78
+ - lib/duck_record/associations/has_many_association.rb
79
+ - lib/duck_record/associations/has_one_association.rb
80
+ - lib/duck_record/associations/singular_association.rb
69
81
  - lib/duck_record/attribute.rb
70
82
  - lib/duck_record/attribute/user_provided_default.rb
71
83
  - lib/duck_record/attribute_assignment.rb
@@ -86,6 +98,9 @@ files:
86
98
  - lib/duck_record/inheritance.rb
87
99
  - lib/duck_record/locale/en.yml
88
100
  - lib/duck_record/model_schema.rb
101
+ - lib/duck_record/nested_attributes.rb
102
+ - lib/duck_record/nested_validate_association.rb
103
+ - lib/duck_record/reflection.rb
89
104
  - lib/duck_record/serialization.rb
90
105
  - lib/duck_record/translation.rb
91
106
  - lib/duck_record/type.rb