HasRemote 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ *.log
2
+ /doc
3
+ /rdoc
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009-2010 Innovation Factory, Amsterdam
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,135 @@
1
+ = HasRemote
2
+
3
+ Gives your local ActiveRecord model an ActiveResource equivalent,
4
+ which enables you to look for certain attributes remotely using a RESTful webservice.
5
+
6
+ === Installation
7
+
8
+ script/plugins install git://github.com/innovationfactory/has_remote.git
9
+
10
+ Only if you require synchronization:
11
+
12
+ script/generate has_remote_migration
13
+
14
+ rake db:migrate
15
+
16
+ === Examples
17
+
18
+ First make sure your records have a reference to a remote resource:
19
+ add_column :users, :remote_id, :integer
20
+
21
+ The default key is 'remote_id', but this can be changed, see options for has_remote.
22
+
23
+ class User < ActiveRecord::Base
24
+ has_remote :site => 'http://people.local'
25
+ end
26
+
27
+ User.remote_class
28
+ # => User::Remote (subclass of ActiveResource::Base)
29
+
30
+ @user.remote
31
+ # => #<User::Remote:...>
32
+
33
+ @user.remote.username
34
+ # => "User name from remote server"
35
+
36
+ has_remote optionally takes a block which can be used to specify remote attributes:
37
+
38
+ class User < ActiveRecord::Base
39
+ has_remote :site => '...' do |remote|
40
+ remote.attribute :username
41
+ end
42
+ end
43
+
44
+ @user.username
45
+ # => "User name from remote server"
46
+
47
+ Note that the current version of HasRemote only offers read-only support for remote attributes.
48
+
49
+ The <tt>:through</tt> option enables you to specify your own ActiveResource class:
50
+
51
+ class RemoteUser < ActiveResource::Base
52
+ self.site = "people.local"
53
+ self.element_name = "person"
54
+ end
55
+
56
+ class User < ActiveRecord::Base
57
+ has_remote :through => "RemoteUser"
58
+ end
59
+
60
+ See documentation for has_remote for a description of all options.
61
+
62
+ === Caching attributes locally
63
+
64
+ In case certain attributes are used a lot and performance is getting bad, or in case you need to do database operations on remote attributes, like sorting, you can tell has_remote
65
+ to locally cache specific attributes in the following manner:
66
+
67
+ class User < ActiveRecord::Base
68
+ has_remote :site => '...' do |remote|
69
+ remote.attribute :username, :local_cache => true
70
+ remote.attribute :email_address, :as => :email, :local_cache => true
71
+ end
72
+ end
73
+
74
+ This assumes you also have a 'username' and 'email' column in the local 'users' table. Note that when using the
75
+ <tt>:as</tt> option the local column is assumed to be named after this value.
76
+
77
+ === Synchronization of cached attributes
78
+
79
+ There are two ways of keeping the locally cached attributes in sync with their remote values.
80
+
81
+ 1. Inline synchronization
82
+ 2. Rake task <tt>hr:sync</tt>
83
+
84
+ ==== Inline synchronization
85
+
86
+ Synchronize a single record's attributes:
87
+ @user.update_cached_attributes
88
+
89
+ *Tip!* It is often useful to trigger this method by means of a callback in order to initialize remote attributes when the record is created:
90
+ before_create :update_cached_attributes
91
+
92
+ Appending an exclamation mark will also save the record:
93
+ @user.update_cached_attributes!
94
+
95
+ Synchronize all records of one specific model:
96
+ User.synchronize!
97
+
98
+ The latter automatically requests all remote resources that have been changed (including new and deleted records) since the last successful synchronization for this particular model.
99
+ You may need to override the <tt>changed_remotes_since</tt> class method in your model to match your host's REST API.
100
+
101
+ See <tt>HasRemote::Synchronizable</tt> for more information.
102
+
103
+ ==== Rake hr:sync
104
+
105
+ The rake task <tt>hr:sync</tt> is provided to allow easy synchronization from the command line.
106
+ You could set up a cron tab that runs this task regularly to keep the data in sync.
107
+
108
+ By default <tt>hr:sync</tt> updates all records of each model that has remotes. You can limit this to
109
+ certain models by using the <tt>MODELS</tt> variable:
110
+
111
+ rake hr:sync MODELS=Contact,Company
112
+
113
+ === Documentation
114
+
115
+ To generate RDocs for this plugin, from the has_remote directory run:
116
+ rake rdoc
117
+ or from your application's root directory, run:
118
+ rake doc:plugins:has_remote
119
+
120
+ === Testing
121
+
122
+ To run the specs of the plugin, from the has_remote directory run:
123
+ rake spec
124
+
125
+ (This requires you to have both RSpec and Shoulda installed.)
126
+
127
+ === More information & patches
128
+
129
+ - Simple example of how HasRemote simplifies your code: http://gist.github.com/176335
130
+ - Simple API authentication with HasRemote: http://gist.github.com/174497
131
+
132
+ Questions, requests and patches can be directed to sjoerd.andringa[AT]innovationfactory[DOT]nl.
133
+
134
+
135
+ Copyright (c) 2009 Innovation Factory.
data/Rakefile ADDED
@@ -0,0 +1,35 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'rake/rdoctask'
4
+ require 'spec/rake/spectask'
5
+
6
+ desc 'Generate documentation for the has_remote plugin.'
7
+ Rake::RDocTask.new(:rdoc) do |rdoc|
8
+ rdoc.rdoc_dir = 'doc'
9
+ rdoc.title = 'HasRemote'
10
+ rdoc.options << '--line-numbers' << '--inline-source'
11
+ rdoc.rdoc_files.include('README.rdoc')
12
+ rdoc.rdoc_files.include('lib/**/*.rb')
13
+ rdoc.rdoc_files.exclude('lib/generators')
14
+ end
15
+
16
+ desc "Run all RSpec examples"
17
+ Spec::Rake::SpecTask.new('spec') do |t|
18
+ t.spec_files = FileList['spec/**/*_spec.rb']
19
+ t.spec_opts = %w(-cfs)
20
+ end
21
+
22
+ begin
23
+ require 'jeweler'
24
+ Jeweler::Tasks.new do |gemspec|
25
+ gemspec.name = "HasRemote"
26
+ gemspec.summary = "Bind a remote ActiveResource object to your local ActiveRecord objects."
27
+ gemspec.description = "Bind a remote ActiveResource object to your local ActiveRecord objects, delegate attributes and optionally cache remote attributes locally."
28
+ gemspec.email = "sjoerd.andringa@innovationfactory.eu"
29
+ gemspec.homepage = "http://github.com/innovationfactory/has_remote"
30
+ gemspec.authors = ["Sjoerd Andringa"]
31
+ end
32
+ Jeweler::GemcutterTasks.new
33
+ rescue LoadError
34
+ puts "Jeweler not available. Install it with: gem install jeweler"
35
+ end
data/TODO ADDED
@@ -0,0 +1,21 @@
1
+ = Todos and ideas
2
+
3
+ - Creating, updating and destroying resources both locally and remote transparently.
4
+ - Allow for multiple remotes, so that composing one object from different resources becomes a possibility.
5
+ - Make <tt>to_xml</tt> and <tt>to_json</tt> etc. optionally merge in remote attributes (<tt>to_xml(:remotes => :merge)</tt>).
6
+
7
+ <user>
8
+ <id type="integer">1</id>
9
+ <remote_id type="integer">1</remote_id>
10
+ <name>George Remote</name>
11
+ </user>
12
+
13
+ - Make <tt>to_xml</tt> etc. optionally include remotes, so consumers may talk to origins directly (<tt>to_xml(:remotes => :include)</tt>).
14
+
15
+ <user>
16
+ <id type="integer">1</id>
17
+ <remote site="...">
18
+ <id type="integer">1</id>
19
+ <name>George Remote</name>
20
+ </remote>
21
+ </user>
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ ActiveRecord::Base.send(:include, HasRemote)
@@ -0,0 +1,7 @@
1
+ class HasRemoteMigrationGenerator < Rails::Generator::Base #:nodoc:
2
+ def manifest
3
+ record do |m|
4
+ m.migration_template "create_has_remote_synchronizations.erb", File.join("db", "migrate"), :migration_file_name => 'create_has_remote_synchronizations'
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ class CreateHasRemoteSynchronizations < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :has_remote_synchronizations do |t|
4
+ t.string :model_name, :null => false
5
+ t.datetime :latest_change, :null => false
6
+ t.datetime :created_at
7
+ end
8
+ end
9
+
10
+ def self.down
11
+ drop_table :has_remote_synchronizations
12
+ end
13
+ end
@@ -0,0 +1,144 @@
1
+ module HasRemote
2
+
3
+ # Contains class methods regarding synchronization of changed, added and deleted remotes.
4
+ #
5
+ # === Synchronization examples
6
+ #
7
+ # Update all cached attributes, destroy deleted records and add new records for all models that have a remote:
8
+ # HasRemote.synchronize!
9
+ #
10
+ # For users only:
11
+ # User.synchronize!
12
+ #
13
+ # You can also update a single record's cached attributes:
14
+ # @user.update_cached_attributes
15
+ #
16
+ # Or with also saving the record:
17
+ # @user.update_cached_attributes!
18
+ #
19
+ # You can make your application call these methods whenever you need to be sure
20
+ # your cache is up to date or use the 'hr:sync' rake task to synchronize all models
21
+ # from the command line.
22
+ #
23
+ # *Note*
24
+ # All remote resources need to have an 'updated_at' field in order for synchronization to work. Records will
25
+ # be destroyed if their remote resource's 'deleted_at' time lies before the time of synchronization.
26
+ #
27
+ module Synchronizable
28
+
29
+ # Returns an array of all attributes that are locally cached.
30
+ #
31
+ def cached_attributes
32
+ @cached_attributes ||= []
33
+ end
34
+
35
+ # Returns all remote objects that have been changed since the given time or one week ago if no
36
+ # time is given. This may include new and optionally deleted (tagged by a 'deleted_at' attribute) resources.
37
+ #
38
+ # This is used by the <tt>synchronize!</tt> class method. By default
39
+ # it queries '/updated?since=<time>' on your resources URL, where 'time' is
40
+ # the latest updated_at time of the last processed remote objects.
41
+ #
42
+ # You may need to override this method in your model to match your host's REST API or to change
43
+ # the default time, e.g.:
44
+ #
45
+ # def self.changed_remotes_since(time = nil)
46
+ # time ||= 12.hours.ago
47
+ # User::Remote.find :all, :from => :search, :params => {:updated_since => time.strftime('...') }
48
+ # end
49
+ #
50
+ def changed_remotes_since(time = nil)
51
+ time ||= 1.week.ago
52
+ remote_class.find :all, :from => :updated, :params => {:since => time.to_s}
53
+ end
54
+
55
+ # Will update all records that have been created, updated or deleted on the remote host
56
+ # since the last successful synchronization.
57
+ #
58
+ def synchronize!(options = {})
59
+ logger.info( "*** Start synchronizing #{table_name} at #{Time.now.to_s :long} ***\n" )
60
+ @sync_count = 0
61
+ begin
62
+ changed_objects = changed_remotes_since( options[:since] || synchronized_at )
63
+ if changed_objects.any?
64
+ # Do everything within transaction to prevent ending up in half-synchronized situation if an exception is raised.
65
+ transaction { sync_all_records_for(changed_objects) }
66
+ else
67
+ logger.info( " - No #{table_name} to update.\n" )
68
+ end
69
+ rescue => e
70
+ logger.warn( " - Synchronization of #{table_name} failed: #{e} \n #{e.backtrace}" )
71
+ else
72
+ self.synchronized_at = changed_objects.map { |o| time_of_update(o) }.sort.last if changed_objects.any?
73
+ logger.info( " - Synchronized #{@sync_count} #{table_name}.\n" ) if @sync_count > 0
74
+ ensure
75
+ logger.info( "*** Stopped synchronizing #{table_name} at #{Time.now.to_s :long} ***\n" )
76
+ end
77
+ end
78
+
79
+ # Time of the last successful synchronization.
80
+ #
81
+ def synchronized_at
82
+ HasRemote::Synchronization.for(self.name).latest_change
83
+ end
84
+
85
+ private
86
+
87
+ def synchronized_at=(time) #:nodoc:
88
+ HasRemote::Synchronization.create!(:model_name => self.name, :latest_change => time)
89
+ end
90
+
91
+ def sync_all_records_for(resources) #:nodoc:
92
+ resources.each { |resource| sync_all_records_for_resource(resource) }
93
+ end
94
+
95
+ def sync_all_records_for_resource(resource) #:nodoc:
96
+ records = find(:all, :conditions => ["#{remote_foreign_key} = ?", resource.send(remote_primary_key)])
97
+ if records.empty?
98
+ create_record_for_resource(resource) unless deleted?(resource)
99
+ else
100
+ records.each { |record| sync_record_for_resource(record, resource) }
101
+ end
102
+ end
103
+
104
+ def sync_record_for_resource(record, resource) #:nodoc:
105
+ if deleted?(resource)
106
+ delete_record_for_resource(record, resource)
107
+ else
108
+ update_and_save_record_for_resource(record, resource)
109
+ end
110
+ end
111
+
112
+ def update_and_save_record_for_resource(record, resource) #:nodoc:
113
+ was_it_new = record.new_record?
114
+ cached_attributes.each do |remote_attr|
115
+ local_attr = remote_attribute_aliases[remote_attr] || remote_attr
116
+ record.send :write_attribute, local_attr, resource.send(remote_attr)
117
+ end
118
+ record.skip_update_cache = true # Dont update cache again on save:
119
+ if record.save!
120
+ @sync_count += 1
121
+ logger.info( was_it_new ? " - Created #{name.downcase} with id #{record.id}.\n" : " - Updated #{name.downcase} with id #{record.id}.\n" )
122
+ end
123
+ end
124
+
125
+ def delete_record_for_resource(record, resource) #:nodoc:
126
+ record.destroy
127
+ @sync_count += 1
128
+ logger.info( " - Deleted #{name.downcase} with id #{record.id}.\n" )
129
+ end
130
+
131
+ def create_record_for_resource(resource) #:nodoc:
132
+ update_and_save_record_for_resource(new(remote_foreign_key => resource.send(remote_primary_key)), resource)
133
+ end
134
+
135
+ def time_of_update(resource)
136
+ (resource.respond_to?(:deleted_at) && resource.deleted_at) ? resource.deleted_at : resource.updated_at
137
+ end
138
+
139
+ def deleted?(resource)
140
+ resource.respond_to?(:deleted_at) && resource.deleted_at && resource.deleted_at <= Time.now
141
+ end
142
+
143
+ end
144
+ end
@@ -0,0 +1,15 @@
1
+ module HasRemote
2
+
3
+ class Synchronization < ActiveRecord::Base #:nodoc:
4
+ set_table_name 'has_remote_synchronizations'
5
+
6
+ named_scope :for, lambda { |model_name| {:conditions => ["model_name = ?", model_name.to_s.classify] } } do
7
+ def latest_change
8
+ self.find(:first, :order => 'latest_change DESC').latest_change rescue nil
9
+ end
10
+ end
11
+
12
+ validates_presence_of :model_name, :latest_change
13
+ end
14
+
15
+ end
data/lib/has_remote.rb ADDED
@@ -0,0 +1,234 @@
1
+ # The main module for the has_remote plugin. Please see README for more information.
2
+ #
3
+ module HasRemote
4
+
5
+ def self.included(base) #:nodoc:
6
+ base.extend ClassMethods
7
+ end
8
+
9
+ # Returns an array of all models that have a remote.
10
+ #
11
+ def self.models
12
+ # Make sure all models are loaded:
13
+ Dir[File.join(RAILS_ROOT, 'app', 'models', '*.rb')].each { |f| require_dependency f }
14
+
15
+ @models ||= []
16
+ end
17
+
18
+ # Updates cached attributes, destroys deleted records and adds new records of all models that have a remote.
19
+ # Also see HasRemote::Synchronizable.
20
+ #
21
+ def self.synchornize!
22
+ models.each(&:synchronize!)
23
+ end
24
+
25
+ module ClassMethods
26
+
27
+ # Gives your local ActiveRecord model a remote proxy (ActiveResource::Base),
28
+ # which enables you to look for certain attributes remotely.
29
+ #
30
+ # ==== Options
31
+ #
32
+ # [:foreign_key] The name of the column used to store the id of the remote resource. Defaults to :remote_id.
33
+ # [:remote_primary_key] The name of the remote resource's primary key. Defaults to :id.
34
+ # [:site, :user, :password, ...] Basically all ActiveResource configuration settings are available,
35
+ # see http://api.rubyonrails.org/classes/ActiveResource/Base.html
36
+ # [:through] Optional custom ActiveResource class name to use for the proxy. If not set, a default class called
37
+ # "<ModelName>::Remote" will be created dynamically. *Note* that any ActiveResource
38
+ # configuration options will still be applied to this class.
39
+ #
40
+ # ==== Usage
41
+ #
42
+ # class User < ActiveRecord::Base
43
+ # has_remote :site => 'http://people.local'
44
+ # end
45
+ #
46
+ # # In a migration:
47
+ # add_column :users, :remote_id, :integer
48
+ #
49
+ # User.find(1).remote
50
+ # # => #<User::Remote> (inherits from ActiveResource::Base)
51
+ # User.find(1).remote.username
52
+ # # => "User name from remote server"
53
+ #
54
+ # has_remote also takes a block which is passed in a HasRemote::Config object which can be used to specify
55
+ # remote attributes:
56
+ #
57
+ # class User < ActiveRecord::Base
58
+ # has_remote :site => '...' do |remote|
59
+ # remote.attribute :username
60
+ # remote.attribute :full_name, :local_cache => true
61
+ # remote.attribute :email_address, :as => :email
62
+ # end
63
+ # end
64
+ #
65
+ # User.find(1).username
66
+ # # => "User name from remote server"
67
+ #
68
+ def has_remote(options, &block)
69
+ unless options[:through] || self.const_defined?("Remote")
70
+ self.const_set("Remote", ActiveResource::Base.clone)
71
+ end
72
+
73
+ @remote_class = options[:through] ? options.delete(:through).constantize : self::Remote
74
+
75
+ @remote_foreign_key = options.delete(:foreign_key) || :remote_id
76
+
77
+ @remote_primary_key = options.delete(:remote_primary_key) || :id
78
+
79
+ # create extra class methods
80
+ class << self
81
+ attr_reader :remote_class
82
+ attr_reader :remote_foreign_key
83
+ attr_reader :remote_finder
84
+ attr_reader :remote_primary_key
85
+ attr_writer :remote_attribute_aliases
86
+
87
+ def remote_attributes # :nodoc:
88
+ @remote_attributes ||= []
89
+ end
90
+
91
+ def remote_attribute_aliases # :nodoc:
92
+ @remote_attribute_aliases ||= {}
93
+ end
94
+
95
+ include HasRemote::Synchronizable
96
+ end
97
+
98
+ # set ARes to look for correct resource (only if not manually specified)
99
+ unless options[:element_name] || @remote_class.element_name != "remote"
100
+ @remote_class.element_name = self.name.underscore.split('/').last
101
+ end
102
+
103
+ # setup ARes class with given options
104
+ options.each do |option, value|
105
+ @remote_class.send "#{option}=", value
106
+ end
107
+
108
+ attr_accessor :skip_update_cache
109
+
110
+ block.call( Config.new(self) ) if block_given?
111
+
112
+ include InstanceMethods
113
+ HasRemote.models << self
114
+ end
115
+
116
+ end
117
+
118
+ module InstanceMethods
119
+
120
+ # Returns the remote proxy for this record as an <tt>ActiveResource::Base</tt> object. Returns nil
121
+ # if foreign key is nil.
122
+ #
123
+ # *Arguments*
124
+ #
125
+ # - <tt>force_reload</tt>: Forces a reload from the remote server if set to true. Defaults to false.
126
+ #
127
+ def remote(force_reload = false)
128
+ if force_reload || @remote.nil?
129
+ id = self.send(self.class.remote_foreign_key)
130
+ @remote = id ? (self.class.remote_finder ? self.class.remote_finder[id] : self.class.remote_class.find(id)) : nil
131
+ end
132
+ @remote
133
+ end
134
+
135
+ # Checks whether a remote proxy exists.
136
+ #
137
+ def has_remote?
138
+ # NOTE ARes#exists? is broken:
139
+ # https://rails.lighthouseapp.com/projects/8994/tickets/1223-activeresource-head-request-sends-headers-with-a-nil-key
140
+ #
141
+ return !remote(true).nil? rescue false
142
+ end
143
+
144
+ # Synchronizes all locally cached remote attributes to this object and saves the object.
145
+ #
146
+ def update_cached_attributes!
147
+ update_cached_attributes
148
+ save!
149
+ end
150
+
151
+ # Synchronizes all locally cached remote attributes to this object, but does not save the object.
152
+ #
153
+ # Note that when the remote does no longer exist, all remote attributes will be
154
+ # set to nil.
155
+ #
156
+ def update_cached_attributes
157
+ unless self.skip_update_cache || self.class.cached_attributes.empty?
158
+ self.class.cached_attributes.each do |remote_attr|
159
+ local_attr = self.class.remote_attribute_aliases[remote_attr] || remote_attr
160
+ write_attribute(local_attr, has_remote? ? remote(true).send(remote_attr) : nil)
161
+ end
162
+ end
163
+ end
164
+
165
+ end
166
+
167
+ class Config
168
+ def initialize(base) #:nodoc:
169
+ @base = base
170
+ end
171
+
172
+ # Defines a remote attribute. Adds a getter method on instances, which delegates to the remote object.
173
+ #
174
+ # *Options*
175
+ #
176
+ # [:local_cache] If set to true the attribute will also be saved locally. See README for more information
177
+ # about caching and synchronization.
178
+ # [:as] Optionally map remote attribute to this name.
179
+ #
180
+ # *Example*
181
+ #
182
+ # class User < ActiveRecord::Base
183
+ # has_remote :site => '...' do |remote|
184
+ # remote.attribute :name, :local_cache => true
185
+ # remote.attribute :email, :as => :email_address
186
+ # end
187
+ # end
188
+ #
189
+ def attribute(attr_name, options = {})
190
+ method_name = options[:as] || attr_name
191
+
192
+ @base.remote_attributes << attr_name
193
+ @base.remote_attribute_aliases = @base.remote_attribute_aliases.merge(attr_name => method_name)
194
+
195
+ unless options[:local_cache]
196
+ @base.class_eval <<-RB
197
+
198
+ def #{method_name}
199
+ remote.try(:#{attr_name})
200
+ end
201
+
202
+ def #{method_name}=(arg)
203
+ raise NoMethodError.new("Remote attributes can't be set directly in this version of has_remote.")
204
+ end
205
+
206
+ RB
207
+ else
208
+ @base.cached_attributes << attr_name
209
+ end
210
+
211
+ end
212
+
213
+ # Lets you specify custom finder logic to find the record's remote object.
214
+ # It takes a block which is passed in the id of the remote object.
215
+ #
216
+ # (By default <tt>Model.remote_class.find(id)</tt> would be called.)
217
+ #
218
+ # *Example*
219
+ #
220
+ # class User < ActiveRecord::Base
221
+ # has_remote :site => "..." do |remote|
222
+ # remote.finder do |id|
223
+ # User::Remote.find :one, :from => "users/active/#{id}.xml"
224
+ # end
225
+ # end
226
+ # end
227
+ #
228
+ def finder(&block)
229
+ @base.instance_variable_set "@remote_finder", block
230
+ end
231
+
232
+ end
233
+
234
+ end
@@ -0,0 +1,12 @@
1
+ namespace :hr do
2
+
3
+ desc 'Synchronizes all attributes locally cached by has_remote'
4
+ task :sync => :environment do
5
+ models = ENV['MODELS'].nil? ? HasRemote.models : extract_models
6
+ models.each(&:synchronize!)
7
+ end
8
+
9
+ def extract_models
10
+ ENV['MODELS'].split(',').map(&:constantize)
11
+ end
12
+ end
@@ -0,0 +1,21 @@
1
+ module HasRemoteMacros
2
+
3
+ def should_have_remote(&block)
4
+ klass = model_class
5
+ should "have remote resources" do
6
+ assert HasRemote.models.include?(klass)
7
+ end
8
+ end
9
+
10
+ def should_have_remote_attribute(attr_name, options = {})
11
+ klass = model_class
12
+ should "have remote attribute #{attr_name}" do
13
+ assert klass.remote_attributes.include?(attr_name)
14
+ assert klass.cached_attributes.include?(attr_name) if options[:local_cache]
15
+ assert klass.new.respond_to?(options[:as] || attr_name)
16
+ end
17
+ end
18
+
19
+ end
20
+
21
+ Test::Unit::TestCase.extend HasRemoteMacros
@@ -0,0 +1,179 @@
1
+ require File.dirname(__FILE__) + '/spec_helper.rb'
2
+
3
+ context "Given existing remote resources" do
4
+
5
+ before(:each) do
6
+ User.delete_all
7
+ stub_resource 1, :email => "joeremote@foo.bar"
8
+ stub_resource 2, :email => "jane_remote@foo.bar"
9
+ end
10
+
11
+ describe "a user" do
12
+
13
+ it "should return cached attributes" do
14
+ User.should respond_to(:cached_attributes)
15
+ User.cached_attributes.should include(:email)
16
+ end
17
+
18
+ it "should return changed remotes since yesterday" do
19
+ user_1, user_2 = mock(:user), mock(:user)
20
+ time = 1.day.ago
21
+ User::Remote.should_receive(:find).with(:all,{:from => :updated, :params=>{:since=>time.to_s}}).once.and_return([user_1, user_2])
22
+
23
+ User.should respond_to(:changed_remotes_since)
24
+ User.changed_remotes_since(time).should include(user_1, user_2)
25
+ end
26
+
27
+ it "should find last synchronization time" do
28
+ times = []
29
+ 1.upto(3) do |i|
30
+ times << i.days.ago
31
+ HasRemote::Synchronization.create(:model_name => 'HasRemoteSpec::User', :latest_change => times.last)
32
+ end
33
+ User.should respond_to(:synchronized_at)
34
+ User.synchronized_at.to_s.should == times.first.to_s
35
+ end
36
+
37
+ it "should not delegate cached remote attributes" do
38
+ user = User.create! :remote_id => 1
39
+ User::Remote.should_not_receive(:find)
40
+ user.email.should == "joeremote@foo.bar"
41
+ end
42
+
43
+ it "should update its cached remote attributes on save" do
44
+ user = User.create! :remote_id => 1
45
+ user[:email].should == "joeremote@foo.bar"
46
+ user.update_attributes(:remote_id => 2)
47
+ user[:email].should == "jane_remote@foo.bar"
48
+ end
49
+
50
+ it "should not update its cached remote attributes if skip_update_cache is true" do
51
+ user = User.create! :remote_id => 1, :skip_update_cache => true
52
+ user[:email].should == nil
53
+ end
54
+
55
+ end
56
+
57
+ describe "synchronization" do
58
+
59
+ describe "for the User model" do
60
+
61
+ describe "with updated and deleted remotes" do
62
+
63
+ before(:each) do
64
+ @user_1, @user_2, @user_3 = User.create!(:remote_id => 1), User.create!(:remote_id => 2), User.create!(:remote_id => 3)
65
+
66
+ @yesterday = DateTime.parse 1.day.ago.to_s
67
+
68
+ resources = [
69
+ mock(:user, :id => 1, :email => "changed@foo.bar", :updated_at => @yesterday),
70
+ mock(:user, :id => 2, :email => "altered@foo.bar", :updated_at => 2.days.ago, :deleted_at => nil),
71
+ mock(:user, :id => 3, :email => "deleted@foo.bar", :updated_at => 2.days.ago, :deleted_at => 2.days.ago),
72
+ mock(:user, :id => 4, :email => "new@foo.bar", :updated_at => @yesterday),
73
+ mock(:user, :id => 5, :email => "new-deleted@foo.bar", :updated_at => 2.days.ago, :deleted_at => 2.days.ago),
74
+ ]
75
+ User.stub!(:changed_remotes_since).and_return(resources)
76
+
77
+ lambda { User.synchronize! }.should change(HasRemote::Synchronization, :count).by(1)
78
+ end
79
+
80
+ it "should keep track of the last synchronization" do
81
+ HasRemote::Synchronization.for("HasRemoteSpec::User").latest_change.should == @yesterday
82
+ end
83
+
84
+ it "should update changed users" do
85
+ @user_1.reload[:email].should == "changed@foo.bar"
86
+ @user_2.reload[:email].should == "altered@foo.bar"
87
+ end
88
+
89
+ it "should destroy deleted users" do
90
+ User.exists?(@user_3).should be_false
91
+ end
92
+
93
+ it "should create added users" do
94
+ User.exists?(:remote_id => 4).should be_true
95
+ end
96
+
97
+ it "should not create deleted users" do
98
+ User.exists?(:remote_id => 5).should be_false
99
+ end
100
+ end
101
+
102
+ describe "that fails" do
103
+
104
+ before(:each) do
105
+
106
+ @failure = lambda {
107
+ user_1, user_2 = User.create!(:remote_id => 1), User.create!(:remote_id => 2)
108
+
109
+ yesterday = DateTime.parse 1.day.ago.to_s
110
+
111
+ resources = [
112
+ mock(:user, :id => 1, :email => "changed@foo.bar", :updated_at => yesterday),
113
+ mock(:user, :id => 2, :email => "altered@foo.bar", :updated_at => 2.days.ago)
114
+ ]
115
+
116
+ User.stub!(:changed_remotes_since).and_return(resources)
117
+
118
+ resources.last.should_receive(:send).and_raise "All hell breaks loose" # Raise when attr is read from resource 2.
119
+
120
+ User.synchronize!
121
+ }
122
+ end
123
+
124
+ it "should do it silently" do
125
+ @failure.should_not raise_error
126
+ end
127
+
128
+ it "should not create a synchronization record" do
129
+ @failure.should_not change(HasRemote::Synchronization, :count)
130
+ end
131
+
132
+ end
133
+
134
+ end
135
+
136
+ describe "for a single user" do
137
+
138
+ it "should update the user" do
139
+ user = User.create! :remote_id => 1
140
+ user[:email].should == "joeremote@foo.bar"
141
+
142
+ stub_resource 1, :email => "changed@foo.bar"
143
+
144
+ user.update_cached_attributes!
145
+ user[:email].should == "changed@foo.bar"
146
+ end
147
+
148
+ end
149
+
150
+ end
151
+
152
+ describe "synchronizing new cheeses" do
153
+ before do
154
+ resources = [
155
+ mock(:cheese, :id => 1, :name => "Brie", :updated_at => Date.yesterday)
156
+ ]
157
+ Cheese.stub!(:changed_remotes_since).and_return(resources)
158
+ lambda{ Cheese.synchronize! }.should change(Cheese, :count).from(0).to(1)
159
+ end
160
+
161
+ after { Cheese.delete_all }
162
+
163
+ it "should populate the local 'maturity' attribute with its default database value" do
164
+ Cheese.first.maturity.should == 5
165
+ end
166
+
167
+ it "should populate the local 'smell' attribute with the value set inside of a before_validation callback" do
168
+ Cheese.first.smell.should == 5 * 10
169
+ end
170
+
171
+ end
172
+
173
+ end
174
+
175
+ def stub_resource(id, attrs)
176
+ resource = mock(:user, {:id => id}.merge(attrs))
177
+ User::Remote.stub!(:find).with(id).and_return(resource)
178
+ resource
179
+ end
data/spec/database.yml ADDED
@@ -0,0 +1,3 @@
1
+ test:
2
+ adapter: sqlite3
3
+ database: ":memory:"
@@ -0,0 +1,7 @@
1
+ module HasRemoteSpec
2
+ class RemoteBook < ActiveResource::Base; end
3
+
4
+ class Book < ActiveRecord::Base
5
+ has_remote :through => "HasRemoteSpec::RemoteBook", :foreign_key => :custom_remote_id
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ module HasRemoteSpec
2
+ class Cheese < ActiveRecord::Base
3
+ validates_presence_of :maturity, :smell
4
+ has_remote :site => "http://dummy.local"
5
+
6
+ def before_validation_on_create
7
+ self.smell = self.maturity * 10 if self.smell.nil?
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ module HasRemoteSpec
2
+ class Product < ActiveRecord::Base
3
+ has_remote :site => "http://dummy.local" do |remote|
4
+ remote.finder do |id|
5
+ Product::Remote.find :one, :from => "/special/place/products/#{id}.xml"
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ module HasRemoteSpec
2
+ class User < ActiveRecord::Base
3
+ has_remote :site => "http://dummy.local" do |remote|
4
+ remote.attribute :name
5
+ remote.attribute :email, :local_cache => true
6
+ remote.attribute :phone, :as => :telephone
7
+ end
8
+
9
+ before_save :update_cached_attributes
10
+ end
11
+ end
@@ -0,0 +1,108 @@
1
+ require File.dirname(__FILE__) + '/spec_helper.rb'
2
+
3
+ describe HasRemote do
4
+
5
+ it "should know about registered models" do
6
+ HasRemote.should respond_to(:models)
7
+ HasRemote.models.should include(User, Book)
8
+ end
9
+
10
+ end
11
+
12
+ describe User do
13
+
14
+ it "should respond to 'remote_class' and 'remote_foreign_key'" do
15
+ User.should respond_to(:remote_class, :remote_foreign_key)
16
+ User.remote_class.should == User::Remote
17
+ User.remote_foreign_key.should == :remote_id
18
+ end
19
+
20
+ it "should set remote class' configuration" do
21
+ User.remote_class.site.should_not be_nil
22
+ User.remote_class.element_name.should == "user"
23
+ end
24
+
25
+ it "should return remote attributes" do
26
+ User.remote_attributes.should include(:name, :email)
27
+ end
28
+
29
+ it "should return remote attribute aliases" do
30
+ User.remote_attribute_aliases[:name].should == :name
31
+ User.remote_attribute_aliases[:phone].should == :telephone
32
+ end
33
+
34
+ describe "instances" do
35
+
36
+ before(:each) do
37
+ @user = User.new
38
+ @user.remote_id = 1
39
+ end
40
+
41
+ it "should have a generated remote" do
42
+ User::Remote.should_receive(:find).twice.with(1).and_return( mock(:resource, :name => "John") )
43
+
44
+ @user.should respond_to(:remote)
45
+ @user.remote.should respond_to(:name)
46
+ @user.has_remote?.should be_true
47
+ end
48
+
49
+ it "should delegate remote attribute" do
50
+ User::Remote.should_receive(:find).once.with(1).and_return( mock(:resource, :name => "John") )
51
+
52
+ @user.should respond_to(:name)
53
+ @user.name.should == "John"
54
+ end
55
+
56
+ context "without a remote" do
57
+
58
+ before(:each) do
59
+ @user.remote_id = nil
60
+ end
61
+
62
+ it "should return nil for remote attributes" do
63
+ @user.remote.should be_nil
64
+ @user.name.should be_nil
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ describe Book do
71
+
72
+ it "should use a custom remote key" do
73
+ Book.remote_foreign_key.should == :custom_remote_id
74
+ end
75
+
76
+ describe "instances" do
77
+
78
+ it "should have a custom remote" do
79
+ @book = Book.new
80
+ @book.custom_remote_id = 1
81
+
82
+ HasRemoteSpec::RemoteBook.should_receive(:find).twice.with(1).and_return( mock(:resource, :title => "Ruby for Rails") )
83
+
84
+ @book.should respond_to(:remote)
85
+ @book.remote.should respond_to(:title)
86
+ @book.has_remote?.should be_true
87
+ end
88
+ end
89
+ end
90
+
91
+ describe Product do
92
+
93
+ it "should have a custom finder" do
94
+ Product.should respond_to(:remote_finder)
95
+ Product.remote_finder.should be_a(Proc)
96
+ end
97
+
98
+ describe "instances" do
99
+
100
+ it "should use a custom finder" do
101
+ @product = Product.new :remote_id => 1
102
+ Product::Remote.should_receive(:find).once.with(:one, :from => "/special/place/products/1.xml").and_return("resource")
103
+
104
+ @product.remote.should == "resource"
105
+ end
106
+ end
107
+
108
+ end
data/spec/schema.rb ADDED
@@ -0,0 +1,29 @@
1
+ ActiveRecord::Migration.verbose = false
2
+
3
+ ActiveRecord::Schema.define(:version => 0) do
4
+ create_table :books do |t|
5
+ t.integer :custom_remote_id
6
+ end
7
+
8
+ create_table :users do |t|
9
+ t.integer :remote_id
10
+ t.string :email
11
+ end
12
+
13
+ create_table :products do |t|
14
+ t.integer :remote_id
15
+ end
16
+
17
+ create_table :has_remote_synchronizations do |t|
18
+ t.string :model_name, :null => false
19
+ t.datetime :latest_change, :null => false
20
+ t.datetime :created_at
21
+ end
22
+
23
+ create_table :cheeses do |t|
24
+ t.string :name
25
+ t.integer :maturity, :default => 5
26
+ t.integer :smell
27
+ t.integer :remote_id
28
+ end
29
+ end
@@ -0,0 +1,38 @@
1
+ # Fake Rails constants
2
+ RAILS_ENV = ENV["RAILS_ENV"] ||= 'test'
3
+ RAILS_ROOT = File.dirname(__FILE__)
4
+
5
+ # Include required libraries
6
+ require 'rubygems'
7
+ require 'active_record'
8
+ require 'active_resource'
9
+ require 'shoulda/active_record/matchers'
10
+ include Shoulda::ActiveRecord::Matchers
11
+
12
+ # Include plugin's files
13
+ require File.dirname(__FILE__) + '/../lib/has_remote'
14
+ require File.dirname(__FILE__) + '/../lib/has_remote/synchronizable'
15
+ require File.dirname(__FILE__) + '/../lib/has_remote/synchronization'
16
+
17
+ # Initialize plugin
18
+ require "#{File.dirname(__FILE__)}/../init"
19
+
20
+ # Create logger
21
+ ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
22
+
23
+ # Setup database connection and structure
24
+ config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
25
+ ActiveRecord::Base.establish_connection(config[ENV['RAILS_ENV']])
26
+ load(File.dirname(__FILE__) + "/schema.rb")
27
+
28
+ # Require models
29
+ require File.dirname(__FILE__) + '/has_remote_spec/user'
30
+ require File.dirname(__FILE__) + '/has_remote_spec/book'
31
+ require File.dirname(__FILE__) + '/has_remote_spec/product'
32
+ require File.dirname(__FILE__) + '/has_remote_spec/cheese'
33
+
34
+ # Create schortcuts
35
+ User = HasRemoteSpec::User
36
+ Book = HasRemoteSpec::Book
37
+ Product = HasRemoteSpec::Product
38
+ Cheese = HasRemoteSpec::Cheese
@@ -0,0 +1,11 @@
1
+ require File.dirname(__FILE__) + '/spec_helper.rb'
2
+
3
+ describe HasRemote::Synchronization do
4
+
5
+ subject { HasRemote::Synchronization.new }
6
+
7
+ it { should validate_presence_of(:model_name) }
8
+ it { should validate_presence_of(:latest_change) }
9
+ it { should have_named_scope("for('User')") }
10
+
11
+ end
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: HasRemote
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sjoerd Andringa
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2010-01-18 00:00:00 +01:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: Bind a remote ActiveResource object to your local ActiveRecord objects, delegate attributes and optionally cache remote attributes locally.
17
+ email: sjoerd.andringa@innovationfactory.eu
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files:
23
+ - README.rdoc
24
+ - TODO
25
+ files:
26
+ - .gitignore
27
+ - MIT-LICENSE
28
+ - README.rdoc
29
+ - Rakefile
30
+ - TODO
31
+ - VERSION
32
+ - init.rb
33
+ - lib/generators/has_remote_migration/has_remote_migration_generator.rb
34
+ - lib/generators/has_remote_migration/templates/create_has_remote_synchronizations.erb
35
+ - lib/has_remote.rb
36
+ - lib/has_remote/synchronizable.rb
37
+ - lib/has_remote/synchronization.rb
38
+ - lib/tasks/has_remote.rake
39
+ - shoulda_macros/has_remote_macros.rb
40
+ - spec/caching_spec.rb
41
+ - spec/database.yml
42
+ - spec/has_remote_spec.rb
43
+ - spec/has_remote_spec/book.rb
44
+ - spec/has_remote_spec/cheese.rb
45
+ - spec/has_remote_spec/product.rb
46
+ - spec/has_remote_spec/user.rb
47
+ - spec/schema.rb
48
+ - spec/spec_helper.rb
49
+ - spec/synchronization_spec.rb
50
+ has_rdoc: true
51
+ homepage: http://github.com/innovationfactory/has_remote
52
+ licenses: []
53
+
54
+ post_install_message:
55
+ rdoc_options:
56
+ - --charset=UTF-8
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: "0"
64
+ version:
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: "0"
70
+ version:
71
+ requirements: []
72
+
73
+ rubyforge_project:
74
+ rubygems_version: 1.3.5
75
+ signing_key:
76
+ specification_version: 3
77
+ summary: Bind a remote ActiveResource object to your local ActiveRecord objects.
78
+ test_files:
79
+ - spec/caching_spec.rb
80
+ - spec/has_remote_spec/book.rb
81
+ - spec/has_remote_spec/cheese.rb
82
+ - spec/has_remote_spec/product.rb
83
+ - spec/has_remote_spec/user.rb
84
+ - spec/has_remote_spec.rb
85
+ - spec/schema.rb
86
+ - spec/spec_helper.rb
87
+ - spec/synchronization_spec.rb