better-ripple 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 (77) hide show
  1. data/LICENSE +17 -0
  2. data/README.md +182 -0
  3. data/RELEASE_NOTES.md +284 -0
  4. data/better-ripple.gemspec +55 -0
  5. data/lib/rails/generators/ripple/configuration/configuration_generator.rb +13 -0
  6. data/lib/rails/generators/ripple/configuration/templates/ripple.yml +25 -0
  7. data/lib/rails/generators/ripple/js/js_generator.rb +13 -0
  8. data/lib/rails/generators/ripple/js/templates/js/contrib.js +63 -0
  9. data/lib/rails/generators/ripple/js/templates/js/iso8601.js +76 -0
  10. data/lib/rails/generators/ripple/js/templates/js/ripple.js +132 -0
  11. data/lib/rails/generators/ripple/model/model_generator.rb +20 -0
  12. data/lib/rails/generators/ripple/model/templates/model.rb.erb +10 -0
  13. data/lib/rails/generators/ripple/observer/observer_generator.rb +16 -0
  14. data/lib/rails/generators/ripple/observer/templates/observer.rb.erb +2 -0
  15. data/lib/rails/generators/ripple/test/templates/cucumber.rb.erb +7 -0
  16. data/lib/rails/generators/ripple/test/test_generator.rb +44 -0
  17. data/lib/rails/generators/ripple_generator.rb +79 -0
  18. data/lib/ripple.rb +86 -0
  19. data/lib/ripple/associations.rb +380 -0
  20. data/lib/ripple/associations/embedded.rb +35 -0
  21. data/lib/ripple/associations/instantiators.rb +26 -0
  22. data/lib/ripple/associations/linked.rb +65 -0
  23. data/lib/ripple/associations/many.rb +38 -0
  24. data/lib/ripple/associations/many_embedded_proxy.rb +39 -0
  25. data/lib/ripple/associations/many_linked_proxy.rb +66 -0
  26. data/lib/ripple/associations/many_reference_proxy.rb +95 -0
  27. data/lib/ripple/associations/many_stored_key_proxy.rb +76 -0
  28. data/lib/ripple/associations/one.rb +20 -0
  29. data/lib/ripple/associations/one_embedded_proxy.rb +35 -0
  30. data/lib/ripple/associations/one_key_proxy.rb +58 -0
  31. data/lib/ripple/associations/one_linked_proxy.rb +26 -0
  32. data/lib/ripple/associations/one_stored_key_proxy.rb +43 -0
  33. data/lib/ripple/associations/proxy.rb +118 -0
  34. data/lib/ripple/attribute_methods.rb +132 -0
  35. data/lib/ripple/attribute_methods/dirty.rb +59 -0
  36. data/lib/ripple/attribute_methods/query.rb +34 -0
  37. data/lib/ripple/attribute_methods/read.rb +28 -0
  38. data/lib/ripple/attribute_methods/write.rb +25 -0
  39. data/lib/ripple/callbacks.rb +71 -0
  40. data/lib/ripple/conflict/basic_resolver.rb +86 -0
  41. data/lib/ripple/conflict/document_hooks.rb +46 -0
  42. data/lib/ripple/conflict/resolver.rb +79 -0
  43. data/lib/ripple/conflict/test_helper.rb +34 -0
  44. data/lib/ripple/conversion.rb +29 -0
  45. data/lib/ripple/core_ext.rb +3 -0
  46. data/lib/ripple/core_ext/casting.rb +151 -0
  47. data/lib/ripple/core_ext/indexes.rb +89 -0
  48. data/lib/ripple/core_ext/object.rb +8 -0
  49. data/lib/ripple/document.rb +105 -0
  50. data/lib/ripple/document/bucket_access.rb +25 -0
  51. data/lib/ripple/document/finders.rb +131 -0
  52. data/lib/ripple/document/key.rb +35 -0
  53. data/lib/ripple/document/link.rb +30 -0
  54. data/lib/ripple/document/persistence.rb +130 -0
  55. data/lib/ripple/embedded_document.rb +63 -0
  56. data/lib/ripple/embedded_document/around_callbacks.rb +18 -0
  57. data/lib/ripple/embedded_document/finders.rb +26 -0
  58. data/lib/ripple/embedded_document/persistence.rb +75 -0
  59. data/lib/ripple/i18n.rb +5 -0
  60. data/lib/ripple/indexes.rb +151 -0
  61. data/lib/ripple/inspection.rb +32 -0
  62. data/lib/ripple/locale/en.yml +26 -0
  63. data/lib/ripple/locale/fr.yml +24 -0
  64. data/lib/ripple/nested_attributes.rb +275 -0
  65. data/lib/ripple/observable.rb +28 -0
  66. data/lib/ripple/properties.rb +74 -0
  67. data/lib/ripple/property_type_mismatch.rb +12 -0
  68. data/lib/ripple/railtie.rb +26 -0
  69. data/lib/ripple/railties/ripple.rake +103 -0
  70. data/lib/ripple/serialization.rb +82 -0
  71. data/lib/ripple/test_server.rb +35 -0
  72. data/lib/ripple/timestamps.rb +25 -0
  73. data/lib/ripple/translation.rb +18 -0
  74. data/lib/ripple/validations.rb +65 -0
  75. data/lib/ripple/validations/associated_validator.rb +43 -0
  76. data/lib/ripple/version.rb +3 -0
  77. metadata +310 -0
@@ -0,0 +1,44 @@
1
+ require 'rails/generators/ripple_generator'
2
+
3
+ module Ripple
4
+ module Generators
5
+ class TestGenerator < Base
6
+ desc 'Generates test helpers for Ripple. Test::Unit, RSpec and Cucumber are supported.'
7
+ # Cucumber
8
+ def create_cucumber_file
9
+ if File.directory?("features/support")
10
+ template 'cucumber.rb.erb', 'features/support/ripple.rb'
11
+ end
12
+ end
13
+
14
+ # RSpec
15
+ def create_rspec_file
16
+ if File.file?('spec/spec_helper.rb')
17
+ rspec_prelude = /\s*R[Ss]pec\.configure do \|config\|/
18
+ indentation = File.binread('spec/spec_helper.rb').match(rspec_prelude)[0].match(/^\s*/)[0]
19
+ inject_into_file 'spec/spec_helper.rb', :before => rspec_prelude do
20
+ "#{indentation}require 'ripple/test_server'\n"
21
+ end
22
+ inject_into_file 'spec/spec_helper.rb', :after => rspec_prelude do
23
+ "\n#{indentation} config.before(:suite) { Ripple::TestServer.setup }" +
24
+ "\n#{indentation} config.after(:each) { Ripple::TestServer.clear }\n"
25
+ end
26
+ end
27
+ end
28
+
29
+ # Test::Unit
30
+ def create_test_unit_file
31
+ if File.file?('test/test_helper.rb')
32
+ test_case_prelude = /\s*class ActiveSupport::TestCase/
33
+ indentation = File.binread('test/test_helper.rb').match(test_case_prelude)[0].match(/^\s*/)[0]
34
+ inject_into_file "test/test_helper.rb", :before => test_case_prelude do
35
+ "#{indentation}# Setup in-memory test server for Riak\n#{indentation}require 'ripple/test_server'\n\n"
36
+ end
37
+ inject_into_class "test/test_helper.rb", 'ActiveSupport::TestCase' do
38
+ "#{indentation} setup { Ripple::TestServer.setup }\n#{indentation} teardown { Ripple::TestServer.clear }\n\n"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,79 @@
1
+ require 'rails/generators'
2
+ require "rails/generators/named_base"
3
+ require "rails/generators/active_model"
4
+
5
+ class RippleGenerator < ::Rails::Generators::Base
6
+ def create_ripple
7
+ invoke "ripple:configuration"
8
+ invoke "ripple:js"
9
+ invoke "ripple:test"
10
+ end
11
+ end
12
+
13
+ module Ripple
14
+ # ActiveModel generators for use in a Rails project.
15
+ module Generators
16
+ # @private
17
+ class Base < ::Rails::Generators::Base
18
+ def self.source_root
19
+ @_ripple_source_root ||=
20
+ File.expand_path("../#{base_name}/#{generator_name}/templates", __FILE__)
21
+ end
22
+ end
23
+
24
+ class NamedBase < ::Rails::Generators::NamedBase
25
+ def self.source_root
26
+ @_ripple_source_root ||=
27
+ File.expand_path("../#{base_name}/#{generator_name}/templates", __FILE__)
28
+ end
29
+ end
30
+
31
+ # Generator for a {Ripple::Document} model
32
+ class ActiveModel < ::Rails::Generators::ActiveModel
33
+ def self.all(klass)
34
+ "#{klass}.list"
35
+ end
36
+
37
+ def self.find(klass, params=nil)
38
+ "#{klass}.find(#{params})"
39
+ end
40
+
41
+ def self.build(klass, params=nil)
42
+ if params
43
+ "#{klass}.new(#{params})"
44
+ else
45
+ "#{klass}.new"
46
+ end
47
+ end
48
+
49
+ def save
50
+ "#{name}.save"
51
+ end
52
+
53
+ def update_attributes(params=nil)
54
+ "#{name}.update_attributes(#{params})"
55
+ end
56
+
57
+ def errors
58
+ "#{name}.errors"
59
+ end
60
+
61
+ def destroy
62
+ "#{name}.destroy"
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ # @private
69
+ module Rails
70
+ module Generators
71
+ class GeneratedAttribute #:nodoc:
72
+ def type_class
73
+ return "Time" if type.to_s == "datetime"
74
+ return "String" if type.to_s == "text"
75
+ return type.to_s.camelcase
76
+ end
77
+ end
78
+ end
79
+ end
data/lib/ripple.rb ADDED
@@ -0,0 +1,86 @@
1
+ require 'riak'
2
+ require 'erb'
3
+ require 'yaml'
4
+ require 'active_model'
5
+ require 'ripple/core_ext'
6
+ require 'ripple/translation'
7
+ require 'ripple/document'
8
+ require 'ripple/embedded_document'
9
+
10
+ # Contains the classes and modules related to the ODM built on top of
11
+ # the basic Riak client.
12
+ module Ripple
13
+ class << self
14
+ # @return [Riak::Client] The client for the current thread.
15
+ def client
16
+ Thread.current[:ripple_client] ||= Riak::Client.new(client_config)
17
+ end
18
+
19
+ # Sets the client for the current thread.
20
+ # @param [Riak::Client] value the client
21
+ def client=(value)
22
+ Thread.current[:ripple_client] = value
23
+ end
24
+
25
+ # Sets the global Ripple configuration.
26
+ def config=(hash)
27
+ self.client = nil
28
+ @config = hash.symbolize_keys
29
+ end
30
+
31
+ # Reads the global Ripple configuration.
32
+ def config
33
+ @config ||= {}
34
+ end
35
+
36
+ # The format in which date/time objects will be serialized to
37
+ # strings in JSON. Defaults to :iso8601, and can be set in
38
+ # Ripple.config.
39
+ # @return [Symbol] the date format
40
+ def date_format
41
+ (config[:date_format] ||= :iso8601).to_sym
42
+ end
43
+
44
+ # Sets the format for date/time objects that are serialized to
45
+ # JSON.
46
+ # @param [Symbol] format the date format
47
+ def date_format=(format)
48
+ config[:date_format] = format.to_sym
49
+ end
50
+
51
+ # Loads the Ripple configuration from a given YAML file.
52
+ # Evaluates the configuration with ERB before loading.
53
+ def load_configuration(config_file, config_keys = [:ripple])
54
+ config_file = File.expand_path(config_file)
55
+ config_hash = YAML.load(ERB.new(File.read(config_file)).result).with_indifferent_access
56
+ config_keys.each {|k| config_hash = config_hash[k]}
57
+ configure_ports(config_hash)
58
+ self.config = config_hash || {}
59
+ rescue Errno::ENOENT
60
+ raise Ripple::MissingConfiguration.new(config_file)
61
+ end
62
+ alias_method :load_config, :load_configuration
63
+
64
+ private
65
+ def client_config
66
+ config.slice(*Riak::Client::VALID_OPTIONS)
67
+ end
68
+
69
+ def configure_ports(config)
70
+ return unless config && config[:min_port]
71
+ config[:http_port] ||= (config[:min_port].to_i)
72
+ config[:pb_port] ||= (config[:min_port].to_i + 1)
73
+ end
74
+ end
75
+
76
+ # Exception raised when the path passed to
77
+ # {Ripple::load_configuration} does not point to a existing file.
78
+ class MissingConfiguration < StandardError
79
+ include Translation
80
+ def initialize(file_path)
81
+ super(t("missing_configuration", :file => file_path))
82
+ end
83
+ end
84
+ end
85
+
86
+ require 'ripple/railtie' if defined? Rails::Railtie
@@ -0,0 +1,380 @@
1
+ require 'active_support/concern'
2
+ require 'active_support/dependencies'
3
+ require 'riak/walk_spec'
4
+ require 'ripple/translation'
5
+ require 'ripple/associations/proxy'
6
+ require 'ripple/associations/instantiators'
7
+ require 'ripple/associations/linked'
8
+ require 'ripple/associations/embedded'
9
+ require 'ripple/associations/many'
10
+ require 'ripple/associations/one'
11
+ require 'ripple/associations/linked'
12
+ require 'ripple/associations/one_embedded_proxy'
13
+ require 'ripple/associations/many_embedded_proxy'
14
+ require 'ripple/associations/one_linked_proxy'
15
+ require 'ripple/associations/many_linked_proxy'
16
+ require 'ripple/associations/many_stored_key_proxy'
17
+ require 'ripple/associations/one_key_proxy'
18
+ require 'ripple/associations/one_stored_key_proxy'
19
+ require 'ripple/associations/many_reference_proxy'
20
+
21
+ module Ripple
22
+ # Adds associations via links and embedding to {Ripple::Document}
23
+ # models. Examples:
24
+ #
25
+ # # Documents can contain embedded documents, and link to other standalone documents
26
+ # # via associations using the many and one class methods.
27
+ # class Person
28
+ # include Ripple::Document
29
+ # property :name, String
30
+ # many :addresses
31
+ # many :friends, :class_name => "Person"
32
+ # one :account
33
+ # end
34
+ #
35
+ # # Account and Address are embeddable documents
36
+ # class Account
37
+ # include Ripple::EmbeddedDocument
38
+ # property :paid_until, Time
39
+ # embedded_in :person # Adds "person" method to get parent document
40
+ # end
41
+ #
42
+ # class Address
43
+ # include Ripple::EmbeddedDocument
44
+ # property :street, String
45
+ # property :city, String
46
+ # property :state, String
47
+ # property :zip, String
48
+ # end
49
+ #
50
+ # person = Person.find("adamhunter")
51
+ # person.friends << Person.find("seancribbs") # Links to people/seancribbs with tag "friend"
52
+ # person.addresses << Address.new(:street => "100 Main Street") # Adds an embedded address
53
+ # person.account.paid_until = 3.months.from_now
54
+ module Associations
55
+ extend ActiveSupport::Concern
56
+
57
+ module ClassMethods
58
+ include Translation
59
+ # @private
60
+ def inherited(subclass)
61
+ super
62
+ subclass.associations.merge!(associations)
63
+ end
64
+
65
+ # Associations defined on the document
66
+ def associations
67
+ @associations ||= {}.with_indifferent_access
68
+ end
69
+
70
+ # Associations of embedded documents
71
+ def embedded_associations
72
+ associations.values.select(&:embedded?)
73
+ end
74
+
75
+ # Associations of linked documents
76
+ def linked_associations
77
+ associations.values.select(&:linked?)
78
+ end
79
+
80
+ # Associations of stored_key documents
81
+ def stored_key_associations
82
+ associations.values.select(&:stored_key?)
83
+ end
84
+
85
+ # Creates a singular association
86
+ def one(name, options={})
87
+ configure_for_key_correspondence if options[:using] === :key
88
+ create_association(:one, name, options)
89
+ end
90
+
91
+ # Creates a plural association
92
+ def many(name, options={})
93
+ raise ArgumentError, t('many_key_association') if options[:using] === :key
94
+ create_association(:many, name, options)
95
+ end
96
+
97
+ def configure_for_key_correspondence
98
+ include Ripple::Associations::KeyDelegator
99
+ end
100
+
101
+ private
102
+ def create_association(type, name, options={})
103
+ association = associations[name] = Association.new(type, name, options)
104
+ association.validate!(self)
105
+ association.setup_on(self)
106
+
107
+ define_method(name) do
108
+ get_proxy(association)
109
+ end
110
+
111
+ define_method("#{name}=") do |value|
112
+ get_proxy(association).replace(value)
113
+ value
114
+ end
115
+
116
+ unless association.many?
117
+ define_method("#{name}?") do
118
+ get_proxy(association).present?
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+
125
+ # @private
126
+ def get_proxy(association)
127
+ unless proxy = instance_variable_get(association.ivar)
128
+ proxy = association.proxy_class.new(self, association)
129
+ instance_variable_set(association.ivar, proxy)
130
+ end
131
+ proxy
132
+ end
133
+
134
+ # @private
135
+ def reset_associations
136
+ self.class.associations.each do |name, assoc_object|
137
+ send(name).reset
138
+ end
139
+ end
140
+
141
+ # Adds embedded documents to the attributes
142
+ # @private
143
+ def attributes_for_persistence
144
+ self.class.embedded_associations.inject(super) do |attrs, association|
145
+ documents = instance_variable_get(association.ivar)
146
+ # We must explicitly check #nil? (rather than just saying `if documents`)
147
+ # because documents can be an association proxy that is proxying nil.
148
+ # In this case ruby treats documents as true because it is not _really_ nil,
149
+ # but #nil? will tell us if it is proxying nil.
150
+
151
+ unless documents.nil?
152
+ attrs[association.name] = documents.is_a?(Array) ? documents.map(&:attributes_for_persistence) : documents.attributes_for_persistence
153
+ end
154
+ attrs
155
+ end
156
+ end
157
+
158
+ def propagate_callbacks_to_embedded_associations(name, kind)
159
+ self.class.embedded_associations.each do |association|
160
+ documents = instance_variable_get(association.ivar)
161
+ # We must explicitly check #nil? (rather than just saying `if documents`)
162
+ # because documents can be an association proxy that is proxying nil.
163
+ # In this case ruby treats documents as true because it is not _really_ nil,
164
+ # but #nil? will tell us if it is proxying nil.
165
+ next if documents.nil?
166
+
167
+ Array(documents).each do |doc|
168
+ doc.send("_#{name}_callbacks").each do |callback|
169
+ next unless callback.kind == kind
170
+ doc.send(callback.filter)
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ # Propagates callbacks (save/create/update/destroy) to embedded associated documents.
177
+ # This is necessary so that when a parent is saved, the embedded child's before_save
178
+ # hooks are run as well.
179
+ # @private
180
+ def run_callbacks(name, *args, &block)
181
+ # validation is already propagated to embedded documents via the
182
+ # AssociatedValidator. We don't need to duplicate the propagation here.
183
+ return super if name == :validation
184
+
185
+ propagate_callbacks_to_embedded_associations(name, :before)
186
+ return_value = super
187
+ propagate_callbacks_to_embedded_associations(name, :after)
188
+ return_value
189
+ end
190
+ end
191
+
192
+ # The "reflection" for an association - metadata about how it is
193
+ # configured.
194
+ class Association
195
+ include Ripple::Translation
196
+ attr_reader :type, :name, :options
197
+
198
+ # association options :using, :class_name, :class, :extend,
199
+ # options that may be added :validate
200
+
201
+ def initialize(type, name, options={})
202
+ @type, @name, @options = type, name, options.to_options
203
+ end
204
+
205
+ def validate!(owner)
206
+ # TODO: Refactor this into an association subclass. See also GH #284
207
+ if @options[:using] == :stored_key
208
+ single_name = ActiveSupport::Inflector.singularize(@name.to_s)
209
+ prop_name = "#{single_name}_key"
210
+ prop_name << "s" if many?
211
+ raise ArgumentError, t('stored_key_requires_property', :name => prop_name) unless owner.properties.include?(prop_name)
212
+ end
213
+ end
214
+
215
+ # @return String The class name of the associated object(s)
216
+ def class_name
217
+ @class_name ||= case
218
+ when @options[:class_name]
219
+ @options[:class_name]
220
+ when @options[:class]
221
+ @options[:class].to_s
222
+ when many?
223
+ @name.to_s.classify
224
+ else
225
+ @name.to_s.camelize
226
+ end
227
+ end
228
+
229
+ # @return [Class] The class of the associated object(s)
230
+ def klass
231
+ @klass ||= discover_class
232
+ end
233
+
234
+ # @return [true,false] Is the cardinality of the association > 1
235
+ def many?
236
+ @type == :many
237
+ end
238
+
239
+ # @return [true,false] Is the cardinality of the association == 1
240
+ def one?
241
+ @type == :one
242
+ end
243
+
244
+ # @return [true,false] Is the associated class an EmbeddedDocument
245
+ def embedded?
246
+ klass.embeddable?
247
+ end
248
+
249
+ # TODO: Polymorphic not supported
250
+ # @return [true,false] Does the association support more than one associated class
251
+ def polymorphic?
252
+ false
253
+ end
254
+
255
+ # @return [true,false] Does the association use links
256
+ def linked?
257
+ using == :linked
258
+ end
259
+
260
+ # @return [true,false] Does the association use stored_key
261
+ def stored_key?
262
+ using == :stored_key
263
+ end
264
+
265
+ # @return [String] the instance variable in the owner where the association will be stored
266
+ def ivar
267
+ "@_#{name}"
268
+ end
269
+
270
+ # @return [Class] the association proxy class
271
+ def proxy_class
272
+ @proxy_class ||= proxy_class_name.constantize
273
+ end
274
+
275
+ # @return [String] the class name of the association proxy
276
+ def proxy_class_name
277
+ klass_name = (many? ? 'Many' : 'One') + using.to_s.camelize + ('Polymorphic' if polymorphic?).to_s + 'Proxy'
278
+ "Ripple::Associations::#{klass_name}"
279
+ end
280
+
281
+ # @return [Proc] a filter proc to be used with Enumerable#select for collecting links that belong to this association (only when #linked? is true)
282
+ def link_filter
283
+ linked? ? lambda {|link| link.tag == link_tag } : lambda {|_| false }
284
+ end
285
+
286
+ # @return [String,nil] when #linked? is true, the tag for outgoing links
287
+ def link_tag
288
+ linked? ? Array(link_spec).first.tag : nil
289
+ end
290
+
291
+ def bucket_name
292
+ polymorphic? ? '_' : klass.bucket_name
293
+ end
294
+
295
+ # @return [Riak::WalkSpec] when #linked? is true, a specification for which links to follow to retrieve the associated documents
296
+ def link_spec
297
+ # TODO: support transitive linked associations
298
+ if linked?
299
+ tag = name.to_s
300
+ Riak::WalkSpec.new(:tag => tag, :bucket => bucket_name)
301
+ else
302
+ nil
303
+ end
304
+ end
305
+
306
+ # @return [Symbol] which method is used for representing the association - currently only supports :embedded and :linked
307
+ def using
308
+ @using ||= options[:using] || (embedded? ? :embedded : :linked)
309
+ end
310
+
311
+ # @raise [ArgumentError] if the value does not match the class of the association
312
+ def verify_type!(value, owner)
313
+ unless type_matches?(value)
314
+ raise ArgumentError.new(t('invalid_association_value',
315
+ :name => name,
316
+ :owner => owner.inspect,
317
+ :klass => polymorphic? ? "<polymorphic>" : klass.name,
318
+ :value => value.inspect))
319
+ end
320
+ end
321
+
322
+ # @private
323
+ def type_matches?(value)
324
+ case
325
+ when polymorphic?
326
+ one? || Array === value
327
+ when many?
328
+ Array === value && value.all? {|d| (embedded? && Hash === d) || klass === d }
329
+ when one?
330
+ value.nil? || (embedded? && Hash === value) || value.kind_of?(klass)
331
+ end
332
+ end
333
+
334
+ def uses_search?
335
+ (options[:using] == :reference)
336
+ end
337
+
338
+ def setup_on(model)
339
+ @model = model
340
+ define_callbacks_on(model)
341
+ if uses_search?
342
+ klass.before_save do |o|
343
+ unless o.class.bucket.is_indexed?
344
+ o.class.bucket.enable_index!
345
+ end
346
+ end
347
+ end
348
+ end
349
+
350
+ def define_callbacks_on(klass)
351
+ _association = self
352
+
353
+ klass.before_save do
354
+ if _association.linked? && !@_in_save_loaded_documents_callback
355
+ @_in_save_loaded_documents_callback = true
356
+
357
+ begin
358
+ send(_association.name).loaded_documents.each do |document|
359
+ document.save if document.new? || document.changed?
360
+ end
361
+ ensure
362
+ remove_instance_variable(:@_in_save_loaded_documents_callback)
363
+ end
364
+ end
365
+ end
366
+ end
367
+
368
+ private
369
+ def discover_class
370
+ options[:class] || (@model && find_class(@model, class_name)) || class_name.constantize
371
+ end
372
+
373
+ def find_class(scope, class_name)
374
+ return nil if class_name.include?("::")
375
+ class_sym = class_name.to_sym
376
+ parent_scope = scope.parents.unshift(scope).find {|s| ActiveSupport::Dependencies.local_const_defined?(s, class_sym) }
377
+ parent_scope.const_get(class_sym) if parent_scope
378
+ end
379
+ end
380
+ end