syncano 3.1.4 → 4.0.0.alpha

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 (92) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -1
  3. data/.rspec +2 -0
  4. data/.rubocop.yml +3 -0
  5. data/.ruby-version +1 -0
  6. data/Gemfile +25 -1
  7. data/Guardfile +22 -4
  8. data/README.md +68 -447
  9. data/Rakefile +48 -5
  10. data/circle.yml +10 -0
  11. data/lib/active_attr/dirty.rb +3 -17
  12. data/lib/active_attr/typecasting/hash_typecaster.rb +34 -0
  13. data/lib/active_attr/typecasting_override.rb +29 -0
  14. data/lib/syncano.rb +53 -92
  15. data/lib/syncano/api.rb +13 -0
  16. data/lib/syncano/connection.rb +97 -0
  17. data/lib/syncano/model/associations.rb +121 -0
  18. data/lib/syncano/{active_record/association → model/associations}/base.rb +5 -5
  19. data/lib/syncano/{active_record/association → model/associations}/belongs_to.rb +6 -6
  20. data/lib/syncano/{active_record/association → model/associations}/has_many.rb +15 -9
  21. data/lib/syncano/{active_record/association → model/associations}/has_one.rb +4 -4
  22. data/lib/syncano/model/base.rb +257 -0
  23. data/lib/syncano/{active_record → model}/callbacks.rb +16 -13
  24. data/lib/syncano/{active_record → model}/scope_builder.rb +53 -69
  25. data/lib/syncano/query_builder.rb +19 -129
  26. data/lib/syncano/resources.rb +126 -0
  27. data/lib/syncano/resources/base.rb +304 -300
  28. data/lib/syncano/resources/collection.rb +19 -223
  29. data/lib/syncano/resources/space.rb +29 -0
  30. data/lib/syncano/schema.rb +86 -0
  31. data/lib/syncano/schema/attribute_definition.rb +83 -0
  32. data/lib/syncano/schema/resource_definition.rb +36 -0
  33. data/lib/syncano/scope.rb +10 -0
  34. data/lib/syncano/version.rb +3 -4
  35. data/spec/integration/syncano_spec.rb +228 -0
  36. data/spec/spec_helper.rb +15 -9
  37. data/spec/unit/api_spec.rb +5 -0
  38. data/spec/unit/connection_spec.rb +137 -0
  39. data/spec/unit/query_builder_spec.rb +75 -0
  40. data/spec/unit/resources/collection_spec.rb +36 -0
  41. data/spec/unit/resources/space_spec.rb +28 -0
  42. data/spec/unit/resources_base_spec.rb +185 -0
  43. data/spec/unit/schema/attribute_definition_spec.rb +18 -0
  44. data/spec/unit/schema/resource_definition_spec.rb +25 -0
  45. data/spec/unit/schema_spec.rb +3532 -0
  46. data/spec/unit/syncano_spec.rb +63 -0
  47. data/syncano.gemspec +8 -14
  48. metadata +85 -210
  49. data/lib/generators/syncano/install_generator.rb +0 -17
  50. data/lib/generators/syncano/templates/initializers/syncano.rb +0 -7
  51. data/lib/syncano/active_record/associations.rb +0 -112
  52. data/lib/syncano/active_record/base.rb +0 -318
  53. data/lib/syncano/batch_queue.rb +0 -58
  54. data/lib/syncano/batch_queue_element.rb +0 -33
  55. data/lib/syncano/clients/base.rb +0 -123
  56. data/lib/syncano/clients/rest.rb +0 -79
  57. data/lib/syncano/clients/sync.rb +0 -164
  58. data/lib/syncano/errors.rb +0 -17
  59. data/lib/syncano/jimson_client.rb +0 -66
  60. data/lib/syncano/packets/auth.rb +0 -27
  61. data/lib/syncano/packets/base.rb +0 -70
  62. data/lib/syncano/packets/call.rb +0 -34
  63. data/lib/syncano/packets/call_response.rb +0 -33
  64. data/lib/syncano/packets/error.rb +0 -19
  65. data/lib/syncano/packets/message.rb +0 -30
  66. data/lib/syncano/packets/notification.rb +0 -39
  67. data/lib/syncano/packets/ping.rb +0 -12
  68. data/lib/syncano/resources/admin.rb +0 -26
  69. data/lib/syncano/resources/api_key.rb +0 -108
  70. data/lib/syncano/resources/data_object.rb +0 -316
  71. data/lib/syncano/resources/folder.rb +0 -88
  72. data/lib/syncano/resources/notifications/base.rb +0 -103
  73. data/lib/syncano/resources/notifications/create.rb +0 -20
  74. data/lib/syncano/resources/notifications/destroy.rb +0 -20
  75. data/lib/syncano/resources/notifications/message.rb +0 -9
  76. data/lib/syncano/resources/notifications/update.rb +0 -24
  77. data/lib/syncano/resources/project.rb +0 -96
  78. data/lib/syncano/resources/role.rb +0 -11
  79. data/lib/syncano/resources/subscription.rb +0 -12
  80. data/lib/syncano/resources/user.rb +0 -65
  81. data/lib/syncano/response.rb +0 -22
  82. data/lib/syncano/sync_connection.rb +0 -133
  83. data/spec/admins_spec.rb +0 -16
  84. data/spec/api_keys_spec.rb +0 -34
  85. data/spec/collections_spec.rb +0 -67
  86. data/spec/data_objects_spec.rb +0 -113
  87. data/spec/folders_spec.rb +0 -39
  88. data/spec/notifications_spec.rb +0 -43
  89. data/spec/projects_spec.rb +0 -35
  90. data/spec/roles_spec.rb +0 -13
  91. data/spec/sync_resources_spec.rb +0 -35
  92. data/spec/syncano_spec.rb +0 -9
data/Rakefile CHANGED
@@ -1,7 +1,50 @@
1
- require "bundler/gem_tasks"
2
- require "rspec/core/rake_task"
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+ require 'rake/testtask'
3
4
 
4
- RSpec::Core::RakeTask.new
5
+ RSpec::Core::RakeTask.new(:spec)
6
+ task default: [:ci]
5
7
 
6
- task default: :spec
7
- task test: :spec
8
+ desc 'Run specs in isolation'
9
+ task :"spec:isolation" do
10
+ FileList['spec/**/*_spec.rb'].each do |spec|
11
+ sh 'rspec', spec
12
+ end
13
+ end
14
+
15
+ desc 'Run CI tasks'
16
+ task ci: [:spec, :lint, :"spec:isolation"]
17
+
18
+ Rake::TestTask.new(:lint) do |test|
19
+ test.description = 'Run adapter lint tests against memory adapter'
20
+ test.test_files = FileList.new('spec/test/*_test.rb')
21
+ test.libs << 'test'
22
+ test.verbose = true
23
+ end
24
+
25
+ begin
26
+ require 'rubocop/rake_task'
27
+
28
+ Rake::Task[:default].enhance [:rubocop]
29
+
30
+ RuboCop::RakeTask.new do |task|
31
+ task.options << '--display-cop-names' << '--lint'
32
+ end
33
+ end
34
+
35
+ desc 'Run mutant against a specific subject'
36
+ task :mutant do
37
+ subject = ARGV.last
38
+ if subject == 'mutant'
39
+ abort "usage: rake mutant SUBJECT\nexample: rake mutant Syncano::API"
40
+ else
41
+ opts = {
42
+ 'include' => 'lib',
43
+ 'require' => 'syncano',
44
+ 'use' => 'rspec',
45
+ 'ignore-subject' => "#{subject}#respond_to_missing?"
46
+ }.to_a.map { |k, v| "--#{k} #{v}" }.join(' ')
47
+
48
+ exec("bundle exec mutant #{opts} #{subject}")
49
+ end
50
+ end
data/circle.yml ADDED
@@ -0,0 +1,10 @@
1
+ machine:
2
+ ruby:
3
+ version: 2.1.5
4
+ environment:
5
+ API_ROOT: https://v4.hydraengine.com
6
+ dependencies:
7
+ override:
8
+ - gem install bundler -v 1.7
9
+ - bundle install
10
+
@@ -1,4 +1,4 @@
1
- require 'active_support'
1
+ require 'active_support/concern'
2
2
  require 'active_model/dirty'
3
3
  require 'active_attr'
4
4
 
@@ -14,7 +14,7 @@ module ActiveAttr
14
14
  # Overwritten attribute! method
15
15
  # @param [Symbol] name
16
16
  # @param [Hash] options
17
- def attribute!(name, options={})
17
+ def attribute!(name, options = {})
18
18
  super(name, options)
19
19
  define_method("#{name}=") do |value|
20
20
  send("#{name}_will_change!") unless value == read_attribute(name)
@@ -22,19 +22,5 @@ module ActiveAttr
22
22
  end
23
23
  end
24
24
  end
25
-
26
- # Overwritten constructor
27
- # @param [Hash] attributes
28
- # @param [Hash] options
29
- def initialize(attributes = nil, options = {})
30
- super(attributes, options)
31
- (@changed_attributes || {}).clear unless new_record?
32
- end
33
-
34
- # Overwritten save method
35
- def save
36
- @previously_changed = changes
37
- @changed_attributes.clear
38
- end
39
25
  end
40
- end
26
+ end
@@ -0,0 +1,34 @@
1
+ require 'active_support/core_ext/hash/indifferent_access'
2
+
3
+ module ActiveAttr
4
+ module Typecasting
5
+ # Typecasts an Object to a HashWithInddifferentAccess
6
+ #
7
+ # @example Usage
8
+ # typecaster = HashTypecaster.new
9
+ # typecaster.call([[:foo, :bar]]) #=> { foo: :bar }
10
+ #
11
+ # @since 0.5.0
12
+ class HashTypecaster
13
+ # Typecasts an object to a HashWithInddifferentAccess
14
+ #
15
+ # Attempts to convert using #to_h.
16
+ #
17
+ # @example Typecast an Array
18
+ # typecaster.call([[:foo, :bar]]) #=> { foo: :bar }
19
+ #
20
+ # @param [Object, #to_h] value The object to typecast
21
+ #
22
+ # @return [HashWithInddifferentAccess] The result of typecasting
23
+ #
24
+ # @since 0.5.0
25
+ def call(value)
26
+ if value.respond_to? :to_h
27
+ HashWithIndifferentAccess.new(value.to_h)
28
+ else
29
+ HashWithIndifferentAccess.new
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,29 @@
1
+ require 'active_attr/typecasting/big_decimal_typecaster'
2
+ require 'active_attr/typecasting/boolean'
3
+ require 'active_attr/typecasting/boolean_typecaster'
4
+ require 'active_attr/typecasting/date_time_typecaster'
5
+ require 'active_attr/typecasting/date_typecaster'
6
+ require 'active_attr/typecasting/float_typecaster'
7
+ require 'active_attr/typecasting/integer_typecaster'
8
+ require 'active_attr/typecasting/object_typecaster'
9
+ require 'active_attr/typecasting/string_typecaster'
10
+ require 'active_attr/typecasting/hash_typecaster'
11
+ require 'active_attr/typecasting/unknown_typecaster_error'
12
+
13
+ module ActiveAttr
14
+ module Typecasting
15
+ remove_const(:TYPECASTER_MAP) if defined?(TYPECASTER_MAP)
16
+
17
+ TYPECASTER_MAP = {
18
+ BigDecimal => BigDecimalTypecaster,
19
+ Boolean => BooleanTypecaster,
20
+ Date => DateTypecaster,
21
+ DateTime => DateTimeTypecaster,
22
+ Float => FloatTypecaster,
23
+ Integer => IntegerTypecaster,
24
+ Object => ObjectTypecaster,
25
+ String => StringTypecaster,
26
+ Hash => HashTypecaster
27
+ }.freeze
28
+ end
29
+ end
data/lib/syncano.rb CHANGED
@@ -1,107 +1,68 @@
1
+ $: << Dir.pwd
2
+
3
+ require 'faraday'
4
+ require 'active_attr/model'
5
+ require 'active_attr/dirty'
6
+ require 'active_attr/typecasting_override'
7
+ require 'active_support/core_ext/hash/indifferent_access'
8
+ require 'active_support/core_ext/class/attribute.rb'
9
+ require 'active_support/inflector'
1
10
  require 'syncano/version'
11
+ require 'syncano/api'
12
+ require 'syncano/connection'
13
+ require 'syncano/schema'
14
+ require 'syncano/scope'
15
+ require 'syncano/resources'
16
+ require 'syncano/resources/base'
17
+ require 'syncano/resources/collection'
18
+ require 'syncano/resources/space'
19
+ require 'syncano/query_builder'
20
+ require 'syncano/model/base'
2
21
 
3
- # Main class used for instantizing clients and as scope for other classes
4
- class Syncano
5
- # Used for initializing Syncano Rest Client
6
- # @param [Hash] options with keys: instance_name, api_key which can be also provided as constants in the initializer
7
- # @return [Syncano::Clients::Rest] Syncano client.
8
- def self.client(options = {})
9
- auth_data = self.auth_data(options)
10
- client = Syncano::Clients::Rest.new(auth_data[:instance_name], auth_data[:api_key], auth_data[:auth_key])
11
- client.login(options[:username], options[:password]) if client.auth_key.nil? && options[:username].present?
12
- client
13
- end
22
+ module Syncano
23
+ class << self
24
+ def connect(options = {})
25
+ connection = Connection.new(
26
+ options.reverse_merge(api_key: ENV['SYNCANO_API_KEY']))
27
+ connection.authenticate! unless connection.authenticated?
14
28
 
15
- # Used for initializing Syncano Sync Client
16
- # @param [Hash] options with keys: instance_name, api_key which can be also provided as constants in the initializer
17
- # @return [Syncano::Clients::Rest] Syncano client.
18
- def self.sync_client(options = {})
19
- auth_data = self.auth_data(options)
20
- client = Syncano::Clients::Sync.instance(auth_data[:instance_name], auth_data[:api_key], auth_data[:auth_key])
21
- client.login(options[:username], options[:password]) if client.auth_key.nil? && options[:username].present?
22
- client.reconnect
23
- client
29
+ API.new connection
30
+ end
24
31
  end
25
32
 
26
- private
27
-
28
- # Prepares hash with auth data from options or constants in initializer
29
- # @param [Hash] options with keys: instance_name, api_key which can be also provided as constants in the initializer
30
- # @return [Hash]
31
- def self.auth_data(options = {})
32
- instance_name = options[:instance_name] || ::SYNCANO_INSTANCE_NAME
33
- raise 'Syncano instance name cannot be blank!' if instance_name.nil?
33
+ class Error < StandardError; end
34
34
 
35
- api_key = options[:api_key] || ::SYNCANO_API_KEY
36
- raise 'Syncano api key cannot be blank!' if api_key.nil?
35
+ class RuntimeError < StandardError; end
37
36
 
38
- { instance_name: instance_name, api_key: api_key, auth_key: options[:auth_key] }
39
- end
40
- end
37
+ class HTTPError < StandardError
38
+ attr_accessor :body, :original_response
41
39
 
42
- # Jimson client
43
- require 'jimson/client'
44
- require 'syncano/jimson_client'
40
+ def initialize(body, original_response)
41
+ self.body = body
42
+ self.original_response = original_response
43
+ end
45
44
 
46
- # Multi Json
47
- require 'multi_json'
45
+ def inspect
46
+ "<#{self.class.name} #{body} #{original_response}>"
47
+ end
48
48
 
49
- # Eventmachine
50
- require 'eventmachine'
49
+ alias :to_s :inspect
50
+ end
51
51
 
52
- # Singleton
53
- require 'singleton'
52
+ class ClientError < HTTPError; end
53
+ class ServerError < HTTPError; end
54
54
 
55
- # ActiveSupport
56
- require 'active_support/core_ext/hash/indifferent_access'
57
- require 'active_support/core_ext/class/attribute.rb'
58
- require 'active_support/core_ext/object/blank.rb'
59
- require 'active_support/json/decoding.rb'
60
- require 'active_support/json/encoding.rb'
61
- require 'active_support/time_with_zone.rb'
62
- require 'active_support/concern'
63
- require 'active_support/inflector/inflections'
55
+ class UnsupportedStatusError < StandardError
56
+ attr_accessor :original_response
64
57
 
65
- # ActiveModel
66
- require 'active_model/forbidden_attributes_protection'
67
- require 'active_model/attribute_methods'
68
- require 'active_model/dirty'
58
+ def initialize(original_response)
59
+ self.original_response = original_response
60
+ end
69
61
 
70
- # ActiveAttr
71
- require 'active_attr/model'
72
- require 'active_attr/dirty'
62
+ def inspect
63
+ "The server returned unsupported status code #{original_response.status}"
64
+ end
73
65
 
74
- # Syncano
75
- require 'syncano/errors'
76
- require 'syncano/clients/base'
77
- require 'syncano/clients/rest'
78
- require 'syncano/clients/sync'
79
- require 'syncano/sync_connection'
80
- require 'syncano/query_builder'
81
- require 'syncano/batch_queue'
82
- require 'syncano/batch_queue_element'
83
- require 'syncano/response'
84
- require 'syncano/resources/base'
85
- require 'syncano/resources/admin'
86
- require 'syncano/resources/api_key'
87
- require 'syncano/resources/data_object'
88
- require 'syncano/resources/collection'
89
- require 'syncano/resources/folder'
90
- require 'syncano/resources/project'
91
- require 'syncano/resources/role'
92
- require 'syncano/resources/subscription'
93
- require 'syncano/resources/user'
94
- require 'syncano/packets/base'
95
- require 'syncano/packets/auth'
96
- require 'syncano/packets/call'
97
- require 'syncano/packets/call_response'
98
- require 'syncano/packets/error'
99
- require 'syncano/packets/message'
100
- require 'syncano/packets/notification'
101
- require 'syncano/packets/ping'
102
- require 'syncano/resources/notifications/base'
103
- require 'syncano/resources/notifications/create'
104
- require 'syncano/resources/notifications/update'
105
- require 'syncano/resources/notifications/destroy'
106
- require 'syncano/resources/notifications/message'
107
- require 'syncano/active_record/base'
66
+ alias :to_s :inspect
67
+ end
68
+ end
@@ -0,0 +1,13 @@
1
+ module Syncano
2
+ class API
3
+ def initialize(connection)
4
+ self.connection = connection
5
+ schema = ::Syncano::Schema.new(connection)
6
+ schema.process!
7
+ end
8
+
9
+ private
10
+
11
+ attr_accessor :connection
12
+ end
13
+ end
@@ -0,0 +1,97 @@
1
+ require 'json'
2
+
3
+ module Syncano
4
+ class Connection
5
+ API_VERSION = 'v1'
6
+ AUTH_PATH = 'account/auth/'
7
+ METHODS = Set.new [:get, :post, :put, :delete, :head, :patch, :options]
8
+
9
+ def self.api_root
10
+ ENV['API_ROOT']
11
+ end
12
+
13
+ def initialize(options = {})
14
+ self.api_key = options[:api_key]
15
+ self.email = options[:email]
16
+ self.password = options[:password]
17
+
18
+ # TODO: take it easy with SSL for development only, temporary solution
19
+ self.conn = Faraday.new(self.class.api_root, ssl: { verify: false })
20
+ conn.path_prefix = API_VERSION
21
+ conn.request :url_encoded
22
+ end
23
+
24
+ def authenticated?
25
+ !api_key.nil?
26
+ end
27
+
28
+ def authenticate(email, password)
29
+ self.email = email
30
+ self.password = password
31
+ authenticate!
32
+ end
33
+
34
+ def authenticate!
35
+ response = conn.post(AUTH_PATH, email: email, password: password)
36
+ body = parse_response(response)
37
+
38
+ case response
39
+ when Status.successful
40
+ self.api_key = body['account_key']
41
+ when Status.client_error
42
+ raise ClientError.new(body, response)
43
+ end
44
+ end
45
+
46
+ def request(method, path, params = {})
47
+ raise %{Unsupported method "#{method}"} unless METHODS.include? method
48
+ conn.headers['X-API-KEY'] = api_key
49
+ conn.headers['User-Agent'] = "Syncano Ruby Gem #{Syncano::VERSION}"
50
+ response = conn.send(method, path, params)
51
+
52
+ case response
53
+ when Status.no_content
54
+ when Status.successful
55
+ parse_response response
56
+ when Status.client_error # TODO figure out if we want to raise an exception on not found or not
57
+ raise ClientError.new(response.body, response)
58
+ when Status.server_error
59
+ raise ServerError.new(response.body, response)
60
+ else
61
+ raise UnsupportedStatusError.new(response)
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def parse_response(response)
68
+ JSON.parse(response.body)
69
+ end
70
+
71
+ class Status
72
+ class << self
73
+ def successful
74
+ ->(response) { (200...300).include? response.status }
75
+ end
76
+
77
+ def client_error
78
+ ->(response) { (400...500).include? response.status }
79
+ end
80
+
81
+ def no_content
82
+ ->(response) { response.status == 204 }
83
+ end
84
+
85
+ def server_error
86
+ ->(response) { response.status >= 500 }
87
+ end
88
+ end
89
+ end
90
+
91
+ attr_accessor :api_key
92
+ attr_accessor :api_root
93
+ attr_accessor :email
94
+ attr_accessor :password
95
+ attr_accessor :conn
96
+ end
97
+ end
@@ -0,0 +1,121 @@
1
+ require 'syncano/model/associations/belongs_to'
2
+ require 'syncano/model/associations/has_many'
3
+ require 'syncano/model/associations/has_one'
4
+
5
+ module Syncano
6
+ module Model
7
+ # Module with associations functionality for Syncano::Model
8
+ module Associations
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ private
13
+
14
+ class_attribute :_associations
15
+ end
16
+
17
+ # Class methods for Syncano::Model::Associations module
18
+ module ClassMethods
19
+ # Lists hash with associations
20
+ # @return [HashWithIndifferentAccess]
21
+ def associations
22
+ self._associations ||= HashWithIndifferentAccess.new
23
+ end
24
+
25
+ private
26
+
27
+ # Defines belongs_to association
28
+ # @param [Symbol] object_name
29
+ def belongs_to(object_name, options = {})
30
+ association = Syncano::Model::Association::BelongsTo.new(self, object_name, options)
31
+ associations[object_name] = association
32
+
33
+ define_method(object_name) do
34
+ association = self.class.associations[object_name]
35
+ id = send(association.foreign_key)
36
+ scope = scope_builder(association.associated_model).find(id)
37
+ end
38
+
39
+ define_method("#{object_name}=") do |object|
40
+ association = self.class.associations[object_name]
41
+
42
+ unless object.is_a?(association.associated_model)
43
+ raise "Object should be an instance of #{association.associated_model} class"
44
+ end
45
+ send("#{association.foreign_key}=", object.try(:id))
46
+ end
47
+ end
48
+
49
+ # Defines has_one association
50
+ # @param [Symbol] object_name
51
+ def has_one(object_name, options = {})
52
+ association = Syncano::Model::Association::HasOne.new(self, object_name, options)
53
+ associations[object_name] = association
54
+
55
+ define_method(object_name) do
56
+ association = self.class.associations[object_name]
57
+
58
+ scope = scope_builder.new(association.associated_model)
59
+ scope.where("#{association.foreign_key} = ?", id).first if id.present?
60
+ end
61
+
62
+ define_method("#{object_name}=") do |object|
63
+ association = self.class.associations[object_name]
64
+
65
+ unless object.is_a?(association.associated_model)
66
+ raise "Object should be an instance of #{association.associated_model} class"
67
+ end
68
+
69
+ object.send("#{association.foreign_key}=", id)
70
+ object.save unless object.new_record?
71
+ object
72
+ end
73
+
74
+ define_method("build_#{object_name}") do |attributes = {}|
75
+ association = self.class.associations[object_name]
76
+ association.associated_model.new(attributes)
77
+ end
78
+
79
+ define_method("create_#{object_name}") do |attributes = {}|
80
+ association = self.class.associations[object_name]
81
+ association.associated_model.create(attributes)
82
+ end
83
+ end
84
+
85
+ # Defines has_many association
86
+ # @param [Symbol] collection_name
87
+ def has_many(collection_name, options = {})
88
+ association = Syncano::Model::Association::HasMany.new(self, collection_name, options)
89
+ associations[collection_name] = association
90
+
91
+ define_method(collection_name) do
92
+ association = self.class.associations[collection_name]
93
+ association.scope_builder(self)
94
+ end
95
+
96
+ define_method("#{collection_name}=") do |collection|
97
+ association = self.class.associations[collection_name]
98
+ objects_ids = {}
99
+
100
+ collection.each do |object|
101
+ "Object should be an instance of #{association.associated_model} class" unless object.is_a?(association.associated_model)
102
+ objects_ids[object.id] = true
103
+ end
104
+
105
+ send(collection_name).all.each do |object|
106
+ unless objects_ids[object.id]
107
+ object.send("#{association.foreign_key}=", nil)
108
+ object.save unless object.new_record?
109
+ end
110
+ end
111
+
112
+ collection.each do |object|
113
+ object.send("#{association.foreign_key}=", id)
114
+ object.save unless object.new_record?
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end