From 2221e28276812d56ed803c87c98e41b07ac97580 Mon Sep 17 00:00:00 2001 From: Jeremy Evans Date: Sat, 14 May 2011 17:43:36 -0700 Subject: [PATCH] Add around hooks to Sequel::Model Around hooks wrap both the related before and after hooks. In most cases, before and after hooks are sufficient, but if you need to be able to rescue exceptions raised by a before or after hook or the wrapped behavior (e.g. the actual insert/update/delete), you need to use an around hook. For example, you could use an around hook to turn DatabaseErrors caused by constraints into validation errors, assuming you could correctly parse error messages from the database. This only contains the specs and code changes, the update to the hook documentation will come soon. --- lib/sequel/model.rb | 5 ++ lib/sequel/model/base.rb | 86 +++++++++++++++++++------------- spec/model/hooks_spec.rb | 104 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 35 deletions(-) diff --git a/lib/sequel/model.rb b/lib/sequel/model.rb index aa9d0bf40f..b3095acf17 100644 --- a/lib/sequel/model.rb +++ b/lib/sequel/model.rb @@ -80,6 +80,11 @@ class Model # +super+ on the first line of your method, so later hooks are called after earlier hooks. AFTER_HOOKS = [:after_initialize, :after_create, :after_update, :after_save, :after_destroy, :after_validation] + # Hooks that are called around an action. If overridden, these methods must call super + # exactly once if the behavior they wrap is desired. The can be used to rescue exceptions + # raised by the code they wrap or ensure that some behavior is executed no matter what. + AROUND_HOOKS = [:around_create, :around_update, :around_save, :around_destroy, :around_validation] + # Empty instance methods to create that the user can override to get hook/callback behavior. # Just like any other method defined by Sequel, if you override one of these, you should # call +super+ to get the default behavior (while empty by default, they can also be defined diff --git a/lib/sequel/model/base.rb b/lib/sequel/model/base.rb index 8f6abc4a72..0b9a84b9e1 100644 --- a/lib/sequel/model/base.rb +++ b/lib/sequel/model/base.rb @@ -687,10 +687,10 @@ def set_columns(new_columns) # Sequel::Model instance methods that implement basic model functionality. # - # * All of the methods in +HOOKS+ create instance methods that are called + # * All of the methods in +HOOKS+ and +AROUND_HOOKS+ create instance methods that are called # by Sequel when the appropriate action occurs. For example, when destroying - # a model object, Sequel will call +before_destroy+, do the destroy, - # and then call +after_destroy+. + # a model object, Sequel will call +around_destory+, which will call +before_destroy+, do + # the destroy, and then call +after_destroy+. # * The following instance_methods all call the class method of the same # name: columns, dataset, db, primary_key, db_schema. # * All of the methods in +BOOLEAN_SETTINGS+ create attr_writers allowing you @@ -699,6 +699,7 @@ def set_columns(new_columns) # gets the default value from the class by calling the class method of the same name. module InstanceMethods HOOKS.each{|h| class_eval("def #{h}; end", __FILE__, __LINE__)} + AROUND_HOOKS.each{|h| class_eval("def #{h}; yield end", __FILE__, __LINE__)} # Define instance method(s) that calls class method(s) of the # same name, caching the result in an instance variable. Define @@ -1187,12 +1188,14 @@ def validate # artist.errors.full_messages # => ['name cannot be Invalid'] def valid?(opts = {}) errors.clear - if before_validation == false - raise_hook_failure(:validation) if raise_on_failure?(opts) - return false + around_validation do + if before_validation == false + raise_hook_failure(:validation) if raise_on_failure?(opts) + return false + end + validate + after_validation end - validate - after_validation errors.empty? end @@ -1214,9 +1217,11 @@ def _delete_dataset # Internal destroy method, separted from destroy to # allow running inside a transaction def _destroy(opts) - raise_hook_failure(:destroy) if before_destroy == false - _destroy_delete - after_destroy + around_destroy do + raise_hook_failure(:destroy) if before_destroy == false + _destroy_delete + after_destroy + end self end @@ -1262,34 +1267,45 @@ def _refresh(dataset) # Internal version of save, split from save to allow running inside # it's own transaction. def _save(columns, opts) - raise_hook_failure(:save) if before_save == false - if new? - raise_hook_failure(:create) if before_create == false - pk = _insert - @this = nil - @new = false - @was_new = true - after_create + was_new = false + pk = nil + around_save do + raise_hook_failure(:save) if before_save == false + if new? + was_new = true + around_create do + raise_hook_failure(:create) if before_create == false + pk = _insert + @this = nil + @new = false + @was_new = true + after_create + end + else + around_update do + raise_hook_failure(:update) if before_update == false + if columns.empty? + @columns_updated = if opts[:changed] + @values.reject{|k,v| !changed_columns.include?(k)} + else + _save_update_all_columns_hash + end + changed_columns.clear + else # update only the specified columns + @columns_updated = @values.reject{|k, v| !columns.include?(k)} + changed_columns.reject!{|c| columns.include?(c)} + end + _update_columns(@columns_updated) + @this = nil + after_update + end + end after_save + end + if was_new @was_new = nil pk ? _save_refresh : changed_columns.clear else - raise_hook_failure(:update) if before_update == false - if columns.empty? - @columns_updated = if opts[:changed] - @values.reject{|k,v| !changed_columns.include?(k)} - else - _save_update_all_columns_hash - end - changed_columns.clear - else # update only the specified columns - @columns_updated = @values.reject{|k, v| !columns.include?(k)} - changed_columns.reject!{|c| columns.include?(c)} - end - _update_columns(@columns_updated) - @this = nil - after_update - after_save @columns_updated = nil end @modified = false diff --git a/spec/model/hooks_spec.rb b/spec/model/hooks_spec.rb index 2bd5527ba4..7c90f2e58b 100644 --- a/spec/model/hooks_spec.rb +++ b/spec/model/hooks_spec.rb @@ -269,3 +269,107 @@ def validate MODEL_DB.sqls.should == [] end end + +describe "Model around filters" do + before do + MODEL_DB.reset + + @c = Class.new(Sequel::Model(:items)) + @c.class_eval do + columns :id, :x + def _save_refresh(*a) end + end + end + + specify "around_create should be called around new record creation" do + @c.class_eval do + def around_create + MODEL_DB << 'ac_before' + super + MODEL_DB << 'ac_after' + end + end + @c.create(:x => 2) + MODEL_DB.sqls.should == [ 'ac_before', 'INSERT INTO items (x) VALUES (2)', 'ac_after' ] + end + + specify "around_delete should be called around record destruction" do + @c.class_eval do + def around_destroy + MODEL_DB << 'ad_before' + super + MODEL_DB << 'ad_after' + end + end + @c.load(:id=>1, :x => 2).destroy + MODEL_DB.sqls.should == [ 'ad_before', 'DELETE FROM items WHERE (id = 1)', 'ad_after' ] + end + + specify "around_update should be called around updating existing records" do + @c.class_eval do + def around_update + MODEL_DB << 'au_before' + super + MODEL_DB << 'au_after' + end + end + @c.load(:id=>1, :x => 2).save + MODEL_DB.sqls.should == [ 'au_before', 'UPDATE items SET x = 2 WHERE (id = 1)', 'au_after' ] + end + + specify "around_update should be called around saving both new and existing records, around either after_create and after_update" do + @c.class_eval do + def around_update + MODEL_DB << 'au_before' + super + MODEL_DB << 'au_after' + end + def around_create + MODEL_DB << 'ac_before' + super + MODEL_DB << 'ac_after' + end + def around_save + MODEL_DB << 'as_before' + super + MODEL_DB << 'as_after' + end + end + @c.create(:x => 2) + MODEL_DB.sqls.should == [ 'as_before', 'ac_before', 'INSERT INTO items (x) VALUES (2)', 'ac_after', 'as_after' ] + MODEL_DB.sqls.clear + @c.load(:id=>1, :x => 2).save + MODEL_DB.sqls.should == [ 'as_before', 'au_before', 'UPDATE items SET x = 2 WHERE (id = 1)', 'au_after', 'as_after' ] + end + + specify "around_validation should be called around validating records" do + @c.class_eval do + def around_validation + MODEL_DB << 'av_before' + super + MODEL_DB << 'av_after' + end + def validate + MODEL_DB << 'validate' + end + end + @c.new(:x => 2).valid?.should == true + MODEL_DB.sqls.should == [ 'av_before', 'validate', 'av_after' ] + end + + specify "around_validation should be able to catch validation errors and modify them" do + @c.class_eval do + def validate + errors.add(:x, 'foo') + end + end + @c.new(:x => 2).valid?.should == false + @c.class_eval do + def around_validation + super + errors.clear + end + end + @c.new(:x => 2).valid?.should == true + end +end