syncer 0.0.1

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