Skip to content

Commit

Permalink
Add touch plugin, which adds Model#touch for updating an instance's t…
Browse files Browse the repository at this point in the history
…imestamp, as well as touching associations when an instance is updated or destroyed

The touch plugin adds a touch method to model instances, which saves
the object with a modified timestamp.  By default, it uses the
:updated_at column, but you can set which column to use.
It also supports touching of associations, so that when the current
model object is updated or destroyed, the associated rows in the
database can have their modified timestamp updated to the current
timestamp.

Since the instance touch method works on model instances,
it uses Time.now for the timestamp.  The association touching works
on datasets, so it updates all related rows in a single query, using
the SQL standard CURRENT_TIMESTAMP.  Both of these can be overridden
easily if necessary.
  • Loading branch information
jeremyevans committed Sep 11, 2009
1 parent bc59602 commit 400a917
Show file tree
Hide file tree
Showing 4 changed files with 276 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG
@@ -1,5 +1,7 @@
=== HEAD

* Add touch plugin, which adds Model#touch for updating an instance's timestamp, as well as touching associations when an instance is updated or destroyed (jeremyevans)

* Add sql_expr extension, which adds the sql_expr to all objects, giving them easy access to Sequel's DSL (jeremyevans)

* Add active_model plugin, which gives Sequel::Model an ActiveModel compliant API, passes the ActiveModel::Lint tests (jeremyevans)
Expand Down
118 changes: 118 additions & 0 deletions lib/sequel/plugins/touch.rb
@@ -0,0 +1,118 @@
module Sequel
module Plugins
# The touch plugin adds a touch method to model instances, which saves
# the object with a modified timestamp. By default, it uses the
# :updated_at column, but you can set which column to use.
# It also supports touching of associations, so that when the current
# model object is updated or destroyed, the associated rows in the
# database can have their modified timestamp updated to the current
# timestamp.
#
# Since the instance touch method works on model instances,
# it uses Time.now for the timestamp. The association touching works
# on datasets, so it updates all related rows in a single query, using
# the SQL standard CURRENT_TIMESTAMP. Both of these can be overridden
# easily if necessary.
module Touch
# The default column to update when touching
TOUCH_COLUMN_DEFAULT = :updated_at

# Set the touch_column and touched_associations variables for the model.
# Options:
# * :associations - The associations to touch when a model instance is
# updated or destroyed. Can be a symbol for a single association,
# a hash with association keys and column values, or an array of
# symbols and/or hashes. If a symbol is used, the column used
# when updating the associated objects is the model's touch_column.
# If a hash is used, the value is used as the column to update.
# * :column - The column to modify when touching a model instance.
def self.configure(model, opts={})
model.touch_column = opts[:column] || TOUCH_COLUMN_DEFAULT if opts[:column] || !model.touch_column
model.instance_variable_set(:@touched_associations, {})
model.touch_associations(opts[:associations]) if opts[:associations]
end

module ClassMethods
# The column to modify when touching a model instance, as a symbol. Also used
# as the default column when touching associations, if
# the associations don't specify a column.
attr_accessor :touch_column

# A hash specifying the associations to touch when instances are
# updated or destroyed. Keys are association dataset method name symbols and values
# are column name symbols.
attr_reader :touched_associations

# Set the touch_column for the subclass to be the same as the current class.
# Also, create a copy of the touched_associations in the subclass.
def inherited(subclass)
super
subclass.touch_column = touch_column
subclass.instance_variable_set(:@touched_associations, touched_associations.dup)
end

# Add additional associations to be touched. See the :association option
# of the Sequel::Plugin::Touch.configure method for the format of the associations
# arguments.
def touch_associations(*associations)
associations.flatten.each do |a|
a = {a=>touch_column} if a.is_a?(Symbol)
a.each do |k,v|
raise(Error, "invalid association: #{k}") unless r = association_reflection(k)
touched_associations[r.dataset_method] = v
end
end
end
end

module InstanceMethods
# Touch all of the model's touched_associations when destroying the object.
def after_destroy
super
touch_associations
end

# Touch all of the model's touched_associations when updating the object.
def after_update
super
touch_associations
end

# Touch the model object. If a column is not given, use the model's touch_column
# as the column. If the column to use is not one of the model's columns, just
# save the changes to the object instead of attempting to a value that doesn't
# exist.
def touch(column=nil)
if column
set(column=>touch_instance_value)
else
column = model.touch_column
set(column=>touch_instance_value) if columns.include?(column)
end
save_changes
end

private

# The value to use when modifying the touch column for the association datasets. Uses
# the SQL standard CURRENT_TIMESTAMP.
def touch_association_value
Sequel::CURRENT_TIMESTAMP
end

# Directly update the database using the association dataset for each association.
def touch_associations
model.touched_associations.each do |meth, column|
send(meth).update(column=>touch_association_value)
end
end

# The value to use when modifying the touch column for the model instance.
# Uses Time.now to work well with typecasting.
def touch_instance_value
Time.now
end
end
end
end
end
155 changes: 155 additions & 0 deletions spec/extensions/touch_spec.rb
@@ -0,0 +1,155 @@
require File.join(File.dirname(__FILE__), "spec_helper")

describe "Touch plugin" do
before do
@c = Class.new(Sequel::Model) do
def _refresh(*); end
end
p = proc{def touch_instance_value; touch_association_value; end}
@Artist = Class.new(@c, &p).set_dataset(:artists)
@Album = Class.new(@c, &p).set_dataset(:albums)

@Artist.columns :id, :updated_at, :modified_on
@Artist.one_to_many :albums, :class=>@Album, :key=>:artist_id

@Album.columns :id, :updated_at, :modified_on, :artist_id, :original_album_id
@Album.one_to_many :followup_albums, :class=>@Album, :key=>:original_album_id
@Album.many_to_one :artist, :class=>@Artist

@a = @Artist.load(:id=>1)
MODEL_DB.reset
end

specify "should default to using Time.now when setting the column values for model instances" do
c = Class.new(Sequel::Model).set_dataset(:a)
c.plugin :touch
c.columns :id, :updated_at
c.load(:id=>1).touch
MODEL_DB.sqls.first.should =~ /UPDATE a SET updated_at = '[-0-9 :.]+' WHERE \(id = 1\)/
end

specify "should allow #touch instance method for updating the updated_at column" do
@Artist.plugin :touch
@a.touch
MODEL_DB.sqls.should == ["UPDATE artists SET updated_at = CURRENT_TIMESTAMP WHERE (id = 1)"]
end

specify "should have #touch take an argument for the column to touch" do
@Artist.plugin :touch
@a.touch(:modified_on)
MODEL_DB.sqls.should == ["UPDATE artists SET modified_on = CURRENT_TIMESTAMP WHERE (id = 1)"]
end

specify "should be able to specify the default column to touch in the plugin call using the :column option" do
@Artist.plugin :touch, :column=>:modified_on
@a.touch
MODEL_DB.sqls.should == ["UPDATE artists SET modified_on = CURRENT_TIMESTAMP WHERE (id = 1)"]
end

specify "should be able to specify the default column to touch using the touch_column model accessor" do
@Artist.plugin :touch
@Artist.touch_column = :modified_on
@a.touch
MODEL_DB.sqls.should == ["UPDATE artists SET modified_on = CURRENT_TIMESTAMP WHERE (id = 1)"]
end

specify "should be able to specify the associations to touch in the plugin call using the :associations option" do
@Artist.plugin :touch, :associations=>:albums
@a.touch
MODEL_DB.sqls.should == ["UPDATE artists SET updated_at = CURRENT_TIMESTAMP WHERE (id = 1)",
"UPDATE albums SET updated_at = CURRENT_TIMESTAMP WHERE (albums.artist_id = 1)"]
end

specify "should be able to give an array to the :associations option specifying multiple associations" do
@Album.plugin :touch, :associations=>[:artist, :followup_albums]
@Album.load(:id=>4, :artist_id=>1).touch
MODEL_DB.sqls.shift.should == "UPDATE albums SET updated_at = CURRENT_TIMESTAMP WHERE (id = 4)"
MODEL_DB.sqls.sort.should == ["UPDATE albums SET updated_at = CURRENT_TIMESTAMP WHERE (albums.original_album_id = 4)",
"UPDATE artists SET updated_at = CURRENT_TIMESTAMP WHERE (artists.id = 1)"]
end

specify "should be able to give a hash to the :associations option specifying the column to use for each association" do
@Artist.plugin :touch, :associations=>{:albums=>:modified_on}
@a.touch
MODEL_DB.sqls.should == ["UPDATE artists SET updated_at = CURRENT_TIMESTAMP WHERE (id = 1)",
"UPDATE albums SET modified_on = CURRENT_TIMESTAMP WHERE (albums.artist_id = 1)"]
end

specify "should default to using the touch_column as the default touch column for associations" do
@Artist.plugin :touch, :column=>:modified_on, :associations=>:albums
@a.touch
MODEL_DB.sqls.should == ["UPDATE artists SET modified_on = CURRENT_TIMESTAMP WHERE (id = 1)",
"UPDATE albums SET modified_on = CURRENT_TIMESTAMP WHERE (albums.artist_id = 1)"]
end

specify "should allow the mixed use of symbols and hashes inside an array for the :associations option" do
@Album.plugin :touch, :associations=>[:artist, {:followup_albums=>:modified_on}]
@Album.load(:id=>4, :artist_id=>1).touch
MODEL_DB.sqls.shift.should == "UPDATE albums SET updated_at = CURRENT_TIMESTAMP WHERE (id = 4)"
MODEL_DB.sqls.sort.should == ["UPDATE albums SET modified_on = CURRENT_TIMESTAMP WHERE (albums.original_album_id = 4)",
"UPDATE artists SET updated_at = CURRENT_TIMESTAMP WHERE (artists.id = 1)"]
end

specify "should be able to specify the associations to touch via a touch_associations_method" do
@Album.plugin :touch
@Album.touch_associations(:artist, {:followup_albums=>:modified_on})
@Album.load(:id=>4, :artist_id=>1).touch
MODEL_DB.sqls.shift.should == "UPDATE albums SET updated_at = CURRENT_TIMESTAMP WHERE (id = 4)"
MODEL_DB.sqls.sort.should == ["UPDATE albums SET modified_on = CURRENT_TIMESTAMP WHERE (albums.original_album_id = 4)",
"UPDATE artists SET updated_at = CURRENT_TIMESTAMP WHERE (artists.id = 1)"]
end

specify "should touch associated objects when destroying an object" do
@Album.plugin :touch
@Album.touch_associations(:artist, {:followup_albums=>:modified_on})
@Album.load(:id=>4, :artist_id=>1).destroy
MODEL_DB.sqls.shift.should == "DELETE FROM albums WHERE (id = 4)"
MODEL_DB.sqls.sort.should == ["UPDATE albums SET modified_on = CURRENT_TIMESTAMP WHERE (albums.original_album_id = 4)",
"UPDATE artists SET updated_at = CURRENT_TIMESTAMP WHERE (artists.id = 1)"]
end

specify "should not update a column that doesn't exist" do
@Album.plugin :touch, :column=>:x
a = @Album.load(:id=>1)
a.touch
MODEL_DB.sqls.should == []
a.artist_id = 1
a.touch
MODEL_DB.sqls.should == ['UPDATE albums SET artist_id = 1 WHERE (id = 1)']
end

specify "should raise an error if given a column argument in touch that doesn't exist" do
@Artist.plugin :touch
proc{@a.touch(:x)}.should raise_error(Sequel::Error)
end

specify "should raise an Error when a nonexistent association is given" do
@Artist.plugin :touch
proc{@Artist.plugin :touch, :associations=>:blah}.should raise_error(Sequel::Error)
end

specify "should work correctly in subclasses" do
@Artist.plugin :touch
c1 = Class.new(@Artist)
c1.load(:id=>4).touch
MODEL_DB.sqls.should == ["UPDATE artists SET updated_at = CURRENT_TIMESTAMP WHERE (id = 4)"]
MODEL_DB.reset

c1.touch_column = :modified_on
c1.touch_associations :albums
c1.load(:id=>1).touch
MODEL_DB.sqls.should == ["UPDATE artists SET modified_on = CURRENT_TIMESTAMP WHERE (id = 1)",
"UPDATE albums SET modified_on = CURRENT_TIMESTAMP WHERE (albums.artist_id = 1)"]
MODEL_DB.reset

@a.touch
MODEL_DB.sqls.should == ["UPDATE artists SET updated_at = CURRENT_TIMESTAMP WHERE (id = 1)"]
MODEL_DB.reset

@Artist.plugin :touch, :column=>:modified_on, :associations=>:albums
c2 = Class.new(@Artist)
c2.load(:id=>4).touch
MODEL_DB.sqls.should == ["UPDATE artists SET modified_on = CURRENT_TIMESTAMP WHERE (id = 4)",
"UPDATE albums SET modified_on = CURRENT_TIMESTAMP WHERE (albums.artist_id = 4)"]
end
end
1 change: 1 addition & 0 deletions www/pages/plugins
Expand Up @@ -22,6 +22,7 @@
<li><a href="rdoc-plugins/classes/Sequel/Plugins/Subclasses.html">subclasses</a>: Allows easy access all model subclasses and descendent classes, without using ObjectSpace.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/TacticalEagerLoading.html">tactical_eager_loading</a>: Allows you to eagerly load an association for all objects retreived from the same dataset when calling the association method on any of the objects in the dataset.</li>
<li><a href='rdoc-plugins/classes/Sequel/Plugins/Timestamps.html'>timestamps</a>: Creates hooks for automatically setting create and update timestamps.</li>
<li><a href='rdoc-plugins/classes/Sequel/Plugins/Touch.html'>touch</a>: Allows easily updating timestamps via Model#touch, as well as touching associations when model instances are updated or destroyed.</li>
<li><a href='rdoc-plugins/classes/Sequel/Plugins/TypecastOnLoad.html'>typecast_on_load</a>: Fixes bad database typecasting when loading model objects.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/ValidationClassMethods.html">validation_class_methods</a>: Adds backwards compatibility for the legacy class-level validation methods (e.g. validates_presence_of :column).</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/ValidationHelpers.html">validation_helpers</a>: The preferred default validations plugin, which uses instance-level methods.</li>
Expand Down

0 comments on commit 400a917

Please sign in to comment.