syncer 0.0.1

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.
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in syncer.gemspec
4
+ gemspec
5
+
6
+ gem 'guard'
7
+ gem 'rb-readline'
8
+ gem 'growl'
9
+ gem 'guard-rspec'
10
+ gem 'guard-bundler'
data/Guardfile ADDED
@@ -0,0 +1,15 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ guard :bundler do
5
+ watch('Gemfile')
6
+ watch(/^.+\.gemspec/)
7
+ end
8
+
9
+ guard :rspec, :version => 2 do
10
+ watch(%r{^spec/.+_spec\.rb$})
11
+ watch(%r{^lib/syncer/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
12
+ watch('spec/spec_helper.rb') { "spec" }
13
+ watch(%r{^spec/support/(.+)\.rb$}) { "spec" }
14
+ end
15
+
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 Harry Hornreich
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # Syncer
2
+
3
+ TODO: Write a gem description
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'syncer'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install syncer
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ ## Contributing
24
+
25
+ 1. Fork it
26
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
27
+ 3. Commit your changes (`git commit -am 'Added some feature'`)
28
+ 4. Push to the branch (`git push origin my-new-feature`)
29
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+
4
+ # adds a default spec task to run rspec
5
+ require 'rspec/core/rake_task'
6
+ RSpec::Core::RakeTask.new
7
+ task :default => :spec
@@ -0,0 +1,161 @@
1
+ require 'active_record'
2
+
3
+ module Kernel
4
+ # helper that returns object in Hash/Array structure similar to json
5
+ def to_rjson
6
+ JSON.parse(self.to_json)
7
+ end
8
+ end
9
+
10
+ class SyncableRecord < ActiveRecord::Base
11
+ # this is an abstract model that should be inherited from
12
+ self.abstract_class = true
13
+
14
+ validates_presence_of :version
15
+ validates_numericality_of :version, :greater_than_or_equal_to => 0
16
+
17
+ before_validation :zero_version, :on => :create
18
+ after_validation :increment_version, :on => :update
19
+
20
+ def zero_version
21
+ self.version = 0
22
+ end
23
+
24
+ def increment_version
25
+ increment!(:version)
26
+ end
27
+
28
+ # helper to match record with remote record info
29
+ # if remote record has version use it otherwise only id matches (for delete records)
30
+ def matches?(remote_record)
31
+ if remote_record['version']
32
+ id == remote_record['id'] && version >= remote_record['version']
33
+ else
34
+ id == remote_record['id']
35
+ end
36
+ end
37
+
38
+ # helper that checks if remote record has been updated
39
+ def updated?(remote_record)
40
+ id == remote_record['id'] && version > remote_record['version']
41
+ end
42
+
43
+ # finds models associated with temp ids and assigns their real id to the record
44
+ def self.fix_associated_ids(remote_record)
45
+ remote_record.keys.each do |key|
46
+ # if the key is of the form xxx_tmp_id
47
+ next unless /(?<foreign_key_prefix>.*)_tmp_id$/ =~ key
48
+ # then find the xxx class
49
+ next unless model_class = foreign_key_prefix.capitalize.to_class
50
+ # find the class model that matches the tmp_id
51
+ next unless model = model_class.find_by_tmp_id(remote_record["#{foreign_key_prefix}_tmp_id"])
52
+ # save the actual model id
53
+ remote_record["#{foreign_key_prefix}_id"] = model.id
54
+ # remove the xxx_tmp_id key & value
55
+ remote_record.delete(key)
56
+ end
57
+ end
58
+
59
+ # syncs a remote data source with server data state
60
+ # remote state should be provided as a Hash
61
+ # returns a Hash response with state changes for the remote data site
62
+ def self.sync(remote_state)
63
+ # TODO we do not currently check for id's per user
64
+ # TODO check both when creating and when accessing
65
+ # TODO we should at some point delete the tmp_ids
66
+
67
+ remote_saved = remote_state['saved'] || []
68
+ remote_created = remote_state['created'] || []
69
+ remote_deleted = remote_state['deleted'] || []
70
+ remote_updated = remote_state['updated'] || []
71
+
72
+ # build list of all the server records known to remote
73
+ remote_existing_records = remote_saved + remote_updated + remote_deleted
74
+
75
+ # group server records by new, updated and deleted as relates to remote state
76
+ all_records = self.all
77
+ new_records = all_records.find_all do |record|
78
+ !remote_existing_records.detect { |remote_record| record.matches?(remote_record) }
79
+ end
80
+ update_records = all_records.find_all do |record|
81
+ remote_existing_records.detect { |remote_record| record.updated?(remote_record) }
82
+ end
83
+ delete_records = remote_existing_records.find_all do |remote_record|
84
+ !all_records.detect { |record| record.matches?(remote_record) }
85
+ end
86
+
87
+ # created
88
+ created = new_records
89
+
90
+ # handle special case where we have an updated record that was deleted remotely
91
+ # in that case we must recreate it
92
+ special_records = []
93
+ update_records.each do |record|
94
+ match = remote_deleted.detect { |remote_record| record.updated?(remote_record) }
95
+ if match
96
+ created << record
97
+ special_records << record
98
+ end
99
+ end
100
+
101
+ # cleanup by removing these special case records
102
+ # no need to remove from remote_deleted as that we will confirm
103
+ special_records.each { |record| update_records.delete(record) }
104
+
105
+ # create all new remote records and add to created list
106
+ if remote_created
107
+ remote_created.each do |remote_record|
108
+ fix_associated_ids(remote_record)
109
+ rec = self.create(remote_record)
110
+ if rec.persisted?
111
+ response = rec.to_rjson
112
+ created << response
113
+ end
114
+ end
115
+ end
116
+
117
+ # updated
118
+ updated = update_records
119
+
120
+ # update all updated remote records and add to updated list
121
+ if remote_updated
122
+ remote_updated.each do |remote_record|
123
+ fix_associated_ids(remote_record)
124
+ rec = self.find_by_id(remote_record['id'])
125
+ # update the record only if found and if it has not been updated on the server already
126
+ if rec && !rec.updated?(remote_record)
127
+ attrs = remote_record.dup
128
+ attrs.delete("id") # can't update id
129
+ attrs.delete("version") # can't update version
130
+ updated << rec if rec.update_attributes(attrs)
131
+ end
132
+ end
133
+ end
134
+
135
+ # deleted
136
+ deleted = delete_records.map{ |r| {:id => r['id']} if r['id'] }
137
+ deleted.delete(nil)
138
+
139
+ # destroy all deleted remote records and add to deleted list
140
+ if remote_deleted
141
+ remote_deleted.each do |remote_record|
142
+ rec = self.find_by_id(remote_record['id'])
143
+ # destroy the record only if found and if it has not been updated on the server already
144
+ if rec && !rec.updated?(remote_record)
145
+ rec.destroy
146
+ deleted << { :id => remote_record['id'] } if remote_record['id']
147
+ end
148
+ end
149
+ end
150
+
151
+ response = Hash.new
152
+ response[:create] = created unless created.empty?
153
+ response[:update] = updated unless updated.empty?
154
+ response[:delete] = deleted unless deleted.empty?
155
+
156
+ response
157
+
158
+ rescue => error
159
+ { :error => "#{error}" }
160
+ end
161
+ end
@@ -0,0 +1,21 @@
1
+ require 'xstring'
2
+
3
+ class Syncer
4
+ SYNC_ORDER_KEY = '_sync_order'
5
+
6
+ # request keys should be strings and not symbols
7
+ def self.sync(request)
8
+ request = request.to_rjson
9
+ response = {};
10
+ all_sync_request = request
11
+ sync_order = all_sync_request[SYNC_ORDER_KEY];
12
+ # TODO if nil raise an exception
13
+ sync_order.each do |model|
14
+ model_sync_data = all_sync_request[model]
15
+ next unless model_sync_data # it is ok not to have data for each model in the order
16
+ sync_response = model.to_class.sync(model_sync_data)
17
+ response[model] = sync_response
18
+ end
19
+ response.to_rjson
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ class Syncer
2
+ VERSION = "0.0.1"
3
+ end
data/lib/syncer.rb ADDED
@@ -0,0 +1,4 @@
1
+ require "syncer/version"
2
+ require "syncer/syncer"
3
+ require "syncer/syncable_record"
4
+
@@ -0,0 +1,19 @@
1
+ # This file was generated by the `rspec --init` command. Conventionally, all
2
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
3
+ # Require this file using `require "spec_helper.rb"` to ensure that it is only
4
+ # loaded once.
5
+ #
6
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
7
+
8
+ RSpec.configure do |config|
9
+ config.treat_symbols_as_metadata_keys_with_true_values = true
10
+ config.run_all_when_everything_filtered = true
11
+ # config.filter_run :focus
12
+ end
13
+
14
+ # require the gem files
15
+ require_relative '../lib/syncer'
16
+
17
+ # Requires supporting ruby files with custom matchers and macros, etc,
18
+ # in spec/support/ and its subdirectories.
19
+ Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each {|f| require f}
@@ -0,0 +1,5 @@
1
+ class Account < SyncableRecord
2
+ has_many :stocks
3
+
4
+ validates_presence_of :name
5
+ end
@@ -0,0 +1,53 @@
1
+ # setup activerecord
2
+
3
+ # took idea from here -> http://iain.nl/testing-activerecord-in-isolation
4
+
5
+ # establish connection to memory db
6
+ ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
7
+
8
+ # config activerecord to not include type in JSON
9
+ ActiveRecord::Base.include_root_in_json = false
10
+
11
+ # create tables needed for specing
12
+ ActiveRecord::Migration.verbose = false
13
+
14
+ ActiveRecord::Migration.create_table "accounts", :force => true do |t|
15
+ t.string "tmp_id"
16
+ t.integer "version"
17
+ t.string "name"
18
+ t.datetime "created_at"
19
+ t.datetime "updated_at"
20
+ end
21
+
22
+ ActiveRecord::Migration.create_table "stocks", :force => true do |t|
23
+ t.string "tmp_id"
24
+ t.integer "version"
25
+ t.string "market"
26
+ t.string "ticker"
27
+ t.string "name"
28
+ t.integer "index"
29
+ t.integer "account_id"
30
+ t.datetime "created_at"
31
+ t.datetime "updated_at"
32
+ end
33
+
34
+ ActiveRecord::Migration.create_table "trades", :force => true do |t|
35
+ t.string "tmp_id"
36
+ t.integer "version"
37
+ t.boolean "buy"
38
+ t.integer "amount"
39
+ t.datetime "date"
40
+ t.integer "stock_id"
41
+ t.datetime "created_at"
42
+ t.datetime "updated_at"
43
+ end
44
+
45
+ #setup transactions
46
+ RSpec.configure do |config|
47
+ config.around do |example|
48
+ ActiveRecord::Base.transaction do
49
+ example.run
50
+ raise ActiveRecord::Rollback
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,20 @@
1
+ require 'factory_girl'
2
+
3
+ FactoryGirl.define do
4
+ factory :account do
5
+ name 'Leumi'
6
+ end
7
+
8
+ factory :stock do
9
+ market 'il'
10
+ ticker '629014'
11
+ name 'Teva'
12
+ end
13
+
14
+ factory :trade do
15
+ amount 100
16
+ buy true
17
+ date DateTime.now
18
+ association :stock
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ class Stock < SyncableRecord
2
+ belongs_to :account
3
+ has_many :trades, :dependent => :delete_all
4
+
5
+ MARKET_LIST = %w(il us)
6
+
7
+ validates_presence_of :ticker, :name
8
+ validates_inclusion_of :market, :in => MARKET_LIST
9
+ end
@@ -0,0 +1,6 @@
1
+ class Trade < SyncableRecord
2
+ belongs_to :stock
3
+
4
+ validates_numericality_of :amount, :greater_than => 0
5
+ validates_presence_of :date, :stock
6
+ end
@@ -0,0 +1,447 @@
1
+ require 'spec_helper.rb'
2
+
3
+ describe "SyncableRecord" do
4
+
5
+ describe "with no server records" do
6
+
7
+ describe "and no remote records" do
8
+
9
+ it "it should return empty response" do
10
+ response = Stock.sync({})
11
+ response.should be_empty
12
+ end
13
+ end
14
+
15
+ describe "and created remote records" do
16
+ before(:each) do
17
+ @stock1 = { :market => 'il', :ticker => 'Teva', :name => 'Teva Pharma', :tmp_id => '12' }
18
+ @stock2 = { :market => 'us', :ticker => 'AAPL', :name => 'Apple', :tmp_id => '13' }
19
+ end
20
+
21
+ it "it should create the records and return create response for them" do
22
+ remote_state = { 'created' => [@stock1, @stock2] }
23
+
24
+ response = Stock.sync(HashWithIndifferentAccess.new(remote_state))
25
+
26
+ # check they were created on server
27
+ stocks = Stock.all
28
+ stocks.length.should == 2
29
+ Stock.find_by_name('Teva Pharma').should_not be_nil
30
+ Stock.find_by_name('Apple').should_not be_nil
31
+
32
+ response[:create].length.should == 2
33
+ response[:create][0]['name'].should == @stock1[:name]
34
+ response[:create][0]['version'].should == 0
35
+ response[:create][0]['id'].should_not be_nil
36
+ response[:create][0]['tmp_id'].should == @stock1[:tmp_id]
37
+ response[:create][1]['name'].should == @stock2[:name]
38
+ response[:create][1]['version'].should == 0
39
+ response[:create][1]['id'].should_not be_nil
40
+ response[:create][1]['tmp_id'].should == @stock2[:tmp_id]
41
+ response[:delete].should be_nil
42
+ response[:update].should be_nil
43
+ end
44
+ end
45
+ end
46
+
47
+ describe "with server records" do
48
+ before(:each) do
49
+ @stock1 = FactoryGirl.create(:stock)
50
+ @stock2 = FactoryGirl.create(:stock, :market => 'us')
51
+ end
52
+
53
+ describe "and no remote records" do
54
+
55
+ it "it should return create for server records" do
56
+ response = Stock.sync({})
57
+ response[:create].length.should == 2
58
+ response[:create][0].should == @stock1
59
+ response[:create][1].should == @stock2
60
+ response[:delete].should be_nil
61
+ response[:update].should be_nil
62
+ end
63
+ end
64
+
65
+ describe "already saved on remote" do
66
+
67
+ it "it should return empty response" do
68
+ remote_state = { 'saved' => [@stock1, @stock2] }
69
+ response = Stock.sync(remote_state)
70
+ response.should be_empty
71
+ end
72
+ end
73
+
74
+ describe "one of the remote records is deleted" do
75
+
76
+ it "it should delete the deleted record and keep the saved record" do
77
+ remote_state = { 'saved' => [@stock1], 'deleted' => [@stock2] }
78
+ response = Stock.sync(remote_state)
79
+ response[:delete].length.should == 1
80
+ response[:delete][0][:id].should == @stock2.id
81
+ response[:create].should be_nil
82
+ response[:update].should be_nil
83
+ end
84
+ end
85
+ end
86
+
87
+ describe "with new records on server" do
88
+ before(:each) do
89
+ @stock1 = FactoryGirl.create(:stock)
90
+ @new_stock = FactoryGirl.create(:stock, :market => 'us', :ticker => 'AMZN', :name => 'Amazon')
91
+ end
92
+
93
+ describe "and some saved records on remote" do
94
+
95
+ it "it should return create for new records" do
96
+ remote_state = { 'saved' => [@stock1] }
97
+
98
+ response = Stock.sync(remote_state)
99
+ response[:create].length.should == 1
100
+ response[:create][0].should == @new_stock
101
+ response[:delete].should be_nil
102
+ response[:update].should be_nil
103
+ end
104
+ end
105
+
106
+ describe "and saved & created remote records" do
107
+ before(:each) do
108
+ @stock3 = { :market => 'il', :ticker => 'Teva', :name => 'Teva Pharma', :tmp_id => '12' }
109
+ @stock4 = { :market => 'us', :ticker => 'AAPL', :name => 'Apple', :tmp_id => '13' }
110
+ end
111
+
112
+ it "it should create the new and created records and return create response for them" do
113
+ remote_state = { 'saved' => [@stock1], 'created' => [@stock3, @stock4] }
114
+
115
+ response = Stock.sync(HashWithIndifferentAccess.new(remote_state))
116
+
117
+ # check they were created on server
118
+ stocks = Stock.all
119
+ stocks.length.should == 4
120
+ Stock.find_by_name('Teva Pharma').should_not be_nil
121
+ Stock.find_by_name('Apple').should_not be_nil
122
+ Stock.find_by_name('Amazon').should_not be_nil
123
+
124
+ # check response
125
+ response[:create].length.should == 3
126
+ response[:create][0]['name'].should == @new_stock[:name]
127
+ response[:create][0]['version'].should == @new_stock[:version]
128
+ response[:create][1]['name'].should == @stock3[:name]
129
+ response[:create][1]['version'].should == 0
130
+ response[:create][1]['id'].should_not be_nil
131
+ response[:create][1]['tmp_id'].should == @stock3[:tmp_id]
132
+ response[:create][2]['name'].should == @stock4[:name]
133
+ response[:create][2]['version'].should == 0
134
+ response[:create][2]['id'].should_not be_nil
135
+ response[:create][2]['tmp_id'].should == @stock4[:tmp_id]
136
+ response[:delete].should be_nil
137
+ response[:update].should be_nil
138
+ end
139
+ end
140
+ end
141
+
142
+ describe "with server saved, new, deleted and updated records" do
143
+ before(:each) do
144
+ @stock1 = FactoryGirl.create(:stock)
145
+ @stock2 = FactoryGirl.create(:stock, :market => 'us', :ticker => 'IBM', :name => 'International Business Machines')
146
+ @new_stock = FactoryGirl.create(:stock, :market => 'us', :ticker => 'AMZN', :name => 'Amazon')
147
+ @updated_stock = FactoryGirl.create(:stock, :market => 'us', :ticker => 'APPL', :name => 'Apple')
148
+ @deleted_stock = FactoryGirl.create(:stock, :market => 'il', :ticker => 'Chil', :name => 'Israel Chemicals')
149
+ end
150
+
151
+ describe "and only saved remote records" do
152
+
153
+ it "it should create the new, update the updated and delete the deleted" do
154
+ remote_state = { 'saved' => [@stock1, @stock2, @updated_stock.to_rjson, @deleted_stock] }
155
+
156
+ # do server changes: update a stock, delete a stock
157
+ @updated_stock.update_attributes({:name => "Apple Computers"})
158
+ @deleted_stock.destroy
159
+
160
+ response = Stock.sync(HashWithIndifferentAccess.new(remote_state))
161
+
162
+ # check nothing was created on server
163
+ stocks = Stock.all
164
+ stocks.length.should == 4
165
+
166
+ # check response
167
+ response[:create].length.should == 1
168
+ response[:create][0].should == @new_stock
169
+ response[:update].length.should == 1
170
+ response[:update][0].should == @updated_stock
171
+ @updated_stock[:version].should == 1
172
+ @updated_stock[:name].should == "Apple Computers"
173
+ response[:delete].length.should == 1
174
+ response[:delete][0][:id].should == @deleted_stock.id
175
+ end
176
+ end
177
+
178
+ describe "and saved & deleted remote records" do
179
+
180
+ it "it should create the new, delete the deleted and update the updated" do
181
+ remote_state = { 'saved' => [@stock2, @updated_stock.to_rjson, @deleted_stock], 'deleted' => [@stock1] }
182
+
183
+ # do server changes: update a stock, delete a stock
184
+ @updated_stock.update_attributes({:name => "Apple Computers"})
185
+ @deleted_stock.destroy
186
+
187
+ response = Stock.sync(HashWithIndifferentAccess.new(remote_state))
188
+
189
+ # check record was deleted on server
190
+ stocks = Stock.all
191
+ stocks.length.should == 3
192
+ Stock.find_by_id(@stock1.id).should be_false
193
+
194
+ # check response
195
+ response[:create].length.should == 1
196
+ response[:create][0].should == @new_stock
197
+ response[:update].length.should == 1
198
+ response[:update][0].should == @updated_stock
199
+ response[:delete].length.should == 2
200
+ response[:delete][0][:id].should == @deleted_stock.id
201
+ response[:delete][1][:id].should == @stock1.id
202
+ end
203
+ end
204
+
205
+ describe "and saved & updated remote records" do
206
+
207
+ it "it should create the new, delete the deleted and update the updated" do
208
+ remote_update = @stock1
209
+ remote_update['name'] = 'This is an updated name'
210
+ remote_state = { 'saved' => [@stock1, @stock2, @updated_stock, @deleted_stock], 'updated' => [remote_update] }
211
+ remote_state = remote_state.to_rjson
212
+
213
+ # do server changes: update a stock, delete a stock
214
+ @updated_stock.update_attributes({:name => "Apple Computers"})
215
+ @deleted_stock.destroy
216
+
217
+ # update what we expect in the response
218
+ remote_update['version'] += 1
219
+
220
+ response = Stock.sync(HashWithIndifferentAccess.new(remote_state))
221
+
222
+ # check nothing was created on server and what needed to be updated was
223
+ stocks = Stock.all
224
+ stocks.length.should == 4
225
+ Stock.find_by_id(@stock1.id).name == remote_update['name']
226
+
227
+ # check response
228
+ response[:create].length.should == 1
229
+ response[:create][0].should == @new_stock
230
+ response[:update].length.should == 2
231
+ response[:update][0].should == @updated_stock
232
+ response[:update][1]['id'].should == remote_update['id']
233
+ response[:update][1]['name'].should == remote_update['name']
234
+ response[:update][1]['version'].should == remote_update['version']
235
+ response[:delete].length.should == 1
236
+ response[:delete][0][:id].should == @deleted_stock[:id]
237
+ end
238
+ end
239
+
240
+ describe "and saved, created, updated, and deleted remote records" do
241
+
242
+ it "it should create the new, delete the deleted and update the updated" do
243
+ remote_update = @stock1
244
+ remote_update['name'] = 'This is an updated name'
245
+ remote_create = { :market => 'il', :ticker => 'Razio', :name => 'Razio Oil', :tmp_id => '19' }
246
+ remote_state = { 'saved' => [@stock1.to_rjson, @updated_stock, @deleted_stock], 'created' => [remote_create], 'updated' => [remote_update], 'deleted' => [@stock2] }
247
+ remote_state = remote_state.to_rjson
248
+
249
+ # do server changes: update a stock, delete a stock
250
+ @updated_stock.update_attributes({:name => "Apple Computers"})
251
+ @deleted_stock.destroy
252
+
253
+ # update what we expect in the response
254
+ remote_update['version'] += 1
255
+
256
+ response = Stock.sync(HashWithIndifferentAccess.new(remote_state))
257
+
258
+ # check nothing was created on server and what needed to be updated was
259
+ stocks = Stock.all
260
+ stocks.length.should == 4
261
+ Stock.find_by_name(remote_create[:name]).name == remote_create['name']
262
+ Stock.find_by_id(@stock1.id).name == remote_update['name']
263
+ Stock.find_by_id(@stock2.id).should be_false
264
+
265
+ # check response
266
+ response[:create].length.should == 2
267
+ response[:create][0].should == @new_stock
268
+ response[:create][1]['name'].should == remote_create[:name]
269
+ response[:create][1]['version'].should == 0
270
+ response[:create][1]['id'].should_not be_nil
271
+ response[:create][1]['tmp_id'].should == remote_create[:tmp_id]
272
+ response[:update].length.should == 2
273
+ response[:update][0].should == @updated_stock
274
+ response[:update][1]['id'].should == remote_update['id']
275
+ response[:update][1]['name'].should == remote_update['name']
276
+ response[:update][1]['version'].should == remote_update['version']
277
+ response[:delete].length.should == 2
278
+ response[:delete][0][:id].should == @deleted_stock.id
279
+ response[:delete][1][:id].should == @stock2.id
280
+ end
281
+ end
282
+ end
283
+
284
+ describe "with conflicts" do
285
+ before(:each) do
286
+ @stock = FactoryGirl.create(:stock, :market => 'us', :ticker => 'IBM', :name => 'International Business Machines')
287
+ end
288
+
289
+ describe "between server deleted record and remote deleted record" do
290
+
291
+ it "it should delete the record" do
292
+ remote_state = { 'deleted' => [@stock.to_rjson] }
293
+
294
+ # do server changes: delete the stock
295
+ @stock.destroy
296
+
297
+ response = Stock.sync(remote_state)
298
+
299
+ # check nothing was created on server
300
+ stocks = Stock.all
301
+ stocks.length.should == 0
302
+
303
+ # check response
304
+ response[:create].should be_nil
305
+ response[:update].should be_nil
306
+ response[:delete].length.should == 1
307
+ response[:delete][0][:id].should == @stock.id
308
+ end
309
+ end
310
+
311
+ describe "between server deleted record and remote updated record" do
312
+
313
+ it "it should delete the record" do
314
+ remote_update = @stock.to_rjson
315
+ remote_update['name'] = 'Tabulation company'
316
+
317
+ remote_state = { 'updated' => [@stock.to_rjson] }
318
+
319
+ # do server changes: delete the stock
320
+ @stock.destroy
321
+
322
+ response = Stock.sync(remote_state)
323
+
324
+ # check nothing was created on server
325
+ stocks = Stock.all
326
+ stocks.length.should == 0
327
+
328
+ # check response
329
+ response[:create].should be_nil
330
+ response[:update].should be_nil
331
+ response[:delete].length.should == 1
332
+ response[:delete][0][:id].should == @stock.id
333
+ end
334
+ end
335
+
336
+ describe "between server updated record and remote deleted record" do
337
+
338
+ it "it should create the record as if new" do
339
+ remote_state = { 'deleted' => [@stock.to_rjson] }
340
+
341
+ # do server changes: update the stock
342
+ @stock.update_attributes({:name => 'Tabulation company'})
343
+ @stock = Stock.find(@stock.id)
344
+
345
+ response = Stock.sync(remote_state)
346
+
347
+ # check nothing has changed on server
348
+ stocks = Stock.all
349
+ stocks.length.should == 1
350
+
351
+ # check response
352
+ response[:create].length.should == 1
353
+ response[:create][0]['id'].should == @stock[:id]
354
+ response[:create][0]['name'].should == @stock[:name]
355
+ response[:update].should be_nil
356
+ response[:delete].should be_nil
357
+ end
358
+ end
359
+
360
+ describe "between server updated record and remote updated record" do
361
+
362
+ it "it should update the record with the server version" do
363
+ remote_update = @stock.to_rjson
364
+ remote_update['name'] = 'Tabulation company'
365
+
366
+ remote_state = { 'updated' => [remote_update] }
367
+
368
+ # do server changes: update the stock
369
+ @stock.update_attributes({:name => 'ICR'})
370
+ @stock = Stock.find(@stock.id)
371
+
372
+ response = Stock.sync(remote_state)
373
+
374
+ # check nothing has changed on server
375
+ stocks = Stock.all
376
+ stocks.length.should == 1
377
+ @stock.name.should == 'ICR'
378
+
379
+ # check response
380
+ # response = JSON.parse(json_response)
381
+ response[:create].should be_nil
382
+ response[:update].length.should == 1
383
+ response[:update][0]['id'].should == @stock[:id]
384
+ response[:update][0]['name'].should == @stock[:name]
385
+ response[:delete].should be_nil
386
+ end
387
+ end
388
+ end
389
+
390
+ describe "with bad information" do
391
+
392
+ describe "send ilegal data" do
393
+
394
+ it "it should return error with 'Ilegal JSON'" do
395
+ response = Stock.sync(123)
396
+
397
+ response.should == { :error => "can't convert String into Integer" }
398
+ end
399
+ end
400
+
401
+ describe "send nonsense data" do
402
+
403
+ it "it should return empty" do
404
+ response = Stock.sync({ "foo" => 123 })
405
+
406
+ response.should == {}
407
+ end
408
+ end
409
+
410
+ describe "send remote update id that does not exist" do
411
+
412
+ it "it should ignore it" do
413
+ @stock = { :market => 'il', :ticker => 'Teva', :name => 'Teva Pharma' }
414
+
415
+ remote_state = { 'updated' => [@stock] }
416
+
417
+ response = Stock.sync(remote_state)
418
+ response.should be_empty
419
+ end
420
+ end
421
+
422
+ describe "send remote update with ilegal attributes" do
423
+
424
+ it "it should ignore it" do
425
+ @stock = { :marketing => 'yyy', :ticker => 'Teva', :name => 'Teva Pharma' }
426
+
427
+ remote_state = { 'updated' => [@stock] }
428
+
429
+ response = Stock.sync(remote_state)
430
+ response.should be_empty
431
+ end
432
+ end
433
+
434
+ describe "send remote create with ilegal attributes" do
435
+
436
+ it "it should return error" do
437
+ @stock = { :marketing => 'yyy', :ticker => 'Teva', :name => 'Teva Pharma' }
438
+
439
+ remote_state = { 'created' => [@stock] }
440
+
441
+ response = Stock.sync(remote_state)
442
+ response[:error].should_not be_empty
443
+ end
444
+ end
445
+ end
446
+
447
+ end
@@ -0,0 +1,142 @@
1
+ require 'spec_helper.rb'
2
+
3
+ describe "Sync.sync" do
4
+
5
+ let(:stock) { FactoryGirl.create(:stock) }
6
+ let(:syncOrder) { [:Account, :Stock, :Trade] }
7
+ let(:syncOrderKey) { Syncer::SYNC_ORDER_KEY }
8
+
9
+ it "should sync a single Model" do
10
+ updated_stock = { :name => 'Teva Pharma', :id => stock.id, :version => stock.version }
11
+ stocks_sync_data = { :updated => [ updated_stock ] }
12
+ all_sync_data = { syncOrderKey => syncOrder, :Stock => stocks_sync_data }
13
+
14
+ result = Syncer.sync(all_sync_data)
15
+
16
+ stock_results = result['Stock']
17
+ stock_results.should_not be_nil
18
+ update_stock = stock_results['update'][0]
19
+ update_stock['id'].should == stock.id
20
+ update_stock['version'].should == stock.version + 1
21
+ update_stock['market'].should == stock.market
22
+ update_stock['ticker'].should == stock.ticker
23
+ update_stock['name'].should == updated_stock[:name]
24
+ end
25
+
26
+ it "should sync multiple independent Models" do
27
+ updated_stock = { :name => 'Teva Pharma', :id => stock.id, :version => stock.version }
28
+ stocks_sync_data = { :updated => [ updated_stock ] }
29
+ created_account = FactoryGirl.attributes_for(:account)
30
+ created_account['tmp_id'] = "12345abcde"
31
+ accounts_sync_data = { :created => [ created_account ] }
32
+ all_sync_data = { syncOrderKey => syncOrder, :Stock => stocks_sync_data, :Account => accounts_sync_data }
33
+
34
+ result = Syncer.sync(all_sync_data)
35
+
36
+ stock_results = result['Stock']
37
+ stock_results.should_not be_nil
38
+ update_stock = stock_results['update'][0]
39
+ update_stock['id'].should == stock.id
40
+ update_stock['version'].should == stock.version + 1
41
+ update_stock['market'].should == stock.market
42
+ update_stock['ticker'].should == stock.ticker
43
+ update_stock['name'].should == updated_stock[:name]
44
+
45
+ account_results = result['Account']
46
+ account_results.should_not be_nil
47
+ create_account = account_results['create'][0]
48
+ create_account['id'].should_not be_nil
49
+ create_account['tmp_id'].should == "12345abcde"
50
+ create_account['version'].should == 0
51
+ create_account['name'].should == created_account[:name]
52
+ end
53
+
54
+ it "should sync dependent Models with new model where dependency exists" do
55
+ updated_stock = { :name => 'Teva Pharma', :id => stock.id, :version => stock.version }
56
+ stocks_sync_data = { :updated => [ updated_stock ] }
57
+ created_trade = FactoryGirl.attributes_for(:trade, :stock_id => stock.id)
58
+ created_trade['tmp_id'] = "12345abcde"
59
+ trades_sync_data = { :created => [ created_trade ] }
60
+ all_sync_data = { syncOrderKey => syncOrder, :Trade => trades_sync_data, :Stock => stocks_sync_data }
61
+
62
+ result = Syncer.sync(all_sync_data)
63
+
64
+ stock_results = result['Stock']
65
+ update_stock = stock_results['update'][0]
66
+ update_stock['id'].should == stock.id
67
+ update_stock['version'].should == stock.version + 1
68
+ update_stock['market'].should == stock.market
69
+ update_stock['ticker'].should == stock.ticker
70
+ update_stock['name'].should == updated_stock[:name]
71
+
72
+ trade_results = result['Trade']
73
+ create_trade = trade_results['create'][0]
74
+ create_trade['id'].should_not be_nil
75
+ create_trade['tmp_id'].should == "12345abcde"
76
+ create_trade['version'].should == 0
77
+ create_trade['stock_id'].should == stock.id
78
+ create_trade['amount'].should == created_trade[:amount]
79
+ create_trade['buy'].should == created_trade[:buy]
80
+ # TODO maybe also test date that seems to return different ...
81
+ end
82
+
83
+ it "should sync dependent Models when both models are new" do
84
+ created_stock = FactoryGirl.attributes_for(:stock, :name => 'Teva Pharma')
85
+ created_stock['tmp_id'] = "abcde12345"
86
+ stocks_sync_data = { :saved => [ stock ], :created => [ created_stock ] }
87
+ created_trade = FactoryGirl.attributes_for(:trade)
88
+ created_trade['tmp_id'] = "12345abcde"
89
+ created_trade['stock_tmp_id'] = "abcde12345"
90
+ trades_sync_data = { :created => [ created_trade ] }
91
+ all_sync_data = { syncOrderKey => syncOrder, :Trade => trades_sync_data, :Stock => stocks_sync_data }
92
+
93
+ result = Syncer.sync(all_sync_data)
94
+
95
+ stock_results = result['Stock']
96
+ create_stock = stock_results['create'][0]
97
+ create_stock['id'].should_not be_nil
98
+ create_stock['tmp_id'].should == "abcde12345"
99
+ create_stock['version'].should == 0
100
+ create_stock['market'].should == created_stock[:market]
101
+ create_stock['ticker'].should == created_stock[:ticker]
102
+ create_stock['name'].should == created_stock[:name]
103
+
104
+ trade_results = result['Trade']
105
+ create_trade = trade_results['create'][0]
106
+ create_trade['id'].should_not be_nil
107
+ create_trade['tmp_id'].should == "12345abcde"
108
+ create_trade['stock_id'].should == create_stock['id']
109
+ create_trade['version'].should == 0
110
+ create_trade['amount'].should == created_trade[:amount]
111
+ create_trade['buy'].should == created_trade[:buy]
112
+ # TODO maybe also test date that seems to return different ...
113
+ end
114
+
115
+ it "should sync dependent Models with new model where dependent exists and is updated to point to new Model" do
116
+ updated_stock = { :name => 'Teva Pharma', :id => stock.id, :version => stock.version, :account_tmp_id => "12345abcde" }
117
+ stocks_sync_data = { :updated => [ updated_stock ] }
118
+ created_account = FactoryGirl.attributes_for(:account)
119
+ created_account['tmp_id'] = "12345abcde"
120
+ accounts_sync_data = { :created => [ created_account ] }
121
+ all_sync_data = { syncOrderKey => syncOrder, :Account => accounts_sync_data, :Stock => stocks_sync_data }
122
+
123
+ result = Syncer.sync(all_sync_data)
124
+
125
+ account_results = result['Account']
126
+ account_results.should_not be_nil
127
+ create_account = account_results['create'][0]
128
+ create_account['id'].should_not be_nil
129
+ create_account['tmp_id'].should == "12345abcde"
130
+ create_account['version'].should == 0
131
+ create_account['name'].should == created_account[:name]
132
+
133
+ stock_results = result['Stock']
134
+ update_stock = stock_results['update'][0]
135
+ update_stock['id'].should == stock.id
136
+ update_stock['version'].should == stock.version + 1
137
+ update_stock['market'].should == stock.market
138
+ update_stock['ticker'].should == stock.ticker
139
+ update_stock['name'].should == updated_stock[:name]
140
+ update_stock['account_id'].should == create_account['id']
141
+ end
142
+ end
data/syncer.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/syncer/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Harry Hornreich"]
6
+ gem.email = ["harryhorn@gmail.com"]
7
+ gem.description = %q{ActiveRecord syncer}
8
+ gem.summary = %q{ActiveRecord syncer}
9
+ gem.homepage = ""
10
+
11
+ gem.add_runtime_dependency 'activerecord', '>= 2.0'
12
+ gem.add_runtime_dependency 'xstring', '>= 0.0.7'
13
+ gem.add_development_dependency 'rspec', '>= 2.0'
14
+ gem.add_development_dependency 'sqlite3', '>= 1.3'
15
+ gem.add_development_dependency 'factory_girl', "~> 3.1.0"
16
+
17
+ gem.files = `git ls-files`.split($\)
18
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
19
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
20
+ gem.name = "syncer"
21
+ gem.require_paths = ["lib"]
22
+ gem.version = Syncer::VERSION
23
+ end
metadata ADDED
@@ -0,0 +1,154 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: syncer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Harry Hornreich
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-04-12 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activerecord
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '2.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '2.0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: xstring
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: 0.0.7
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: 0.0.7
46
+ - !ruby/object:Gem::Dependency
47
+ name: rspec
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '2.0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: sqlite3
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '1.3'
70
+ type: :development
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '1.3'
78
+ - !ruby/object:Gem::Dependency
79
+ name: factory_girl
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ~>
84
+ - !ruby/object:Gem::Version
85
+ version: 3.1.0
86
+ type: :development
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ~>
92
+ - !ruby/object:Gem::Version
93
+ version: 3.1.0
94
+ description: ActiveRecord syncer
95
+ email:
96
+ - harryhorn@gmail.com
97
+ executables: []
98
+ extensions: []
99
+ extra_rdoc_files: []
100
+ files:
101
+ - .gitignore
102
+ - .rspec
103
+ - Gemfile
104
+ - Guardfile
105
+ - LICENSE
106
+ - README.md
107
+ - Rakefile
108
+ - lib/syncer.rb
109
+ - lib/syncer/syncable_record.rb
110
+ - lib/syncer/syncer.rb
111
+ - lib/syncer/version.rb
112
+ - spec/spec_helper.rb
113
+ - spec/support/account.rb
114
+ - spec/support/active_record.rb
115
+ - spec/support/factories.rb
116
+ - spec/support/stock.rb
117
+ - spec/support/trade.rb
118
+ - spec/syncable_record_spec.rb
119
+ - spec/syncer_spec.rb
120
+ - syncer.gemspec
121
+ homepage: ''
122
+ licenses: []
123
+ post_install_message:
124
+ rdoc_options: []
125
+ require_paths:
126
+ - lib
127
+ required_ruby_version: !ruby/object:Gem::Requirement
128
+ none: false
129
+ requirements:
130
+ - - ! '>='
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ required_rubygems_version: !ruby/object:Gem::Requirement
134
+ none: false
135
+ requirements:
136
+ - - ! '>='
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubyforge_project:
141
+ rubygems_version: 1.8.21
142
+ signing_key:
143
+ specification_version: 3
144
+ summary: ActiveRecord syncer
145
+ test_files:
146
+ - spec/spec_helper.rb
147
+ - spec/support/account.rb
148
+ - spec/support/active_record.rb
149
+ - spec/support/factories.rb
150
+ - spec/support/stock.rb
151
+ - spec/support/trade.rb
152
+ - spec/syncable_record_spec.rb
153
+ - spec/syncer_spec.rb
154
+ has_rdoc: